Repository navigation
feat: MCP 2026-07-28 subscriptions/listen server support and listener/polling hardening (#6571) - #7272
feat: MCP 2026-07-28 subscriptions/listen server support and listener/polling hardening (#6571)#7272msureshkumar88 wants to merge 29 commits into
Conversation
a8e90ce to
9050572
Compare
9050572 to
8cd4a25
Compare
|
8cd4a25 to
2d7da43
Compare
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…e and gateway update/delete Add the missing invalidate_* call after a successful commit so every catalog mutation publishes a listen event. Paths fixed: - ResourceService.register_resource, register_resources_bulk (once per call) - PromptService.register_prompts_bulk (once per call) - ServerService.register_server - GatewayService.update_gateway (both branches), delete_gateway (async-lifecycle and hard-delete): add tools, resources, prompts - GatewayService.transfer_gateway_ownership: ownership and team change on gateway, tools, resources, prompts - ImportService._assign_imported_items_to_team: visibility change after import Audited with no change needed: ToolService create, update, delete, toggle and bulk paths, update_tool_from_a2a_agent (delegates to update_tool), set_gateway_state, _process_deleting_gateway (uses the finalize path), _mark_gateway_reachable (uses set_gateway_state), and the A2A toggle. Regression guards pin set_gateway_state and NotificationService._execute_refresh. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…07-28 clients Register a ContextForge adapter for subscriptions/listen that delegates to the shared ListenHandler from catalog_change_bus. - Reject handshake-era callers with -32601, so legacy behavior is unchanged. - Gate each list kind with the same token-scope check as its */list call. - Raise the tools/list PermissionError when every requested kind is denied. - Drop resourceSubscriptions and unknown filter keys before delegating. - Run the OAuth enforcement once per listen and close its DB session first. - Omit resources.subscribe from 2026-07-28 capabilities. Extract _resolve_catalog_scope() and _token_allows_catalog_read() from _list_catalog_page so list and listen share one gate. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Shorten each health-loop sleep by up to 10 percent so ticks never come later than before. After a failed scheduled auto-refresh, skip the gateway until a jittered, doubling backoff passes. Cap the backoff at the gateway refresh interval and reset it on success. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…tter Ticks now last between 0.9 and 1.0 of the interval. Compare the refresh throttle against 0.9 of the refresh interval so a refresh never slips a tick later than before. Run the scheduler test with real jitter and add a max-jitter test for 300 s refresh on 60 s ticks. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…ern upstream listeners Run modern listeners on one holder only. With CACHE_TYPE=redis the holder owns the REDIS_LEADER_KEY:modern_listener key, renewed by a heartbeat every REDIS_LEADER_HEARTBEAT_INTERVAL. Otherwise a non-blocking FileLock gives one holder per host. Non-holders retry on each reconcile pass. A holder that loses the key cancels its listeners. Spread reconnect delays over 0.5x-1.5x of the exponential backoff. Apply the delay after a graceful close too. Refresh tools, prompts and resources once after each re-listen that follows a drop or close, because listen has no replay. Expose GATEWAY_MODERN_LISTENERS_ENABLED (default false) in .env.example, the Helm values and schema, and the configuration docs. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Replace the dedicated holder lock with is_primary_worker(). The elector selected by PRIMARY_WORKER_ELECTION_BACKEND owns the lease: filelock gives one primary per host, redis one per cluster. A reconcile pass on a non-primary worker cancels its listeners and starts none. Shutdown releases nothing. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…nd modern listener
Start real gateway subprocesses against an SDK upstream whose grow tool adds a
tool and announces it on its listen bus. Prove listen delivery on /mcp and
/servers/{id}/mcp, the ack filter, the deny paths, and the unchanged legacy
handshake. Prove that scheduled polling, manual refresh, and the modern
upstream listener each pick up an upstream change and reach a downstream
listen stream.
Pin two defects found on the way as strict xfails. The gateway API accepts
refresh_interval_seconds but never stores it. NotificationService drops a
listener event that arrives inside the debounce window after the earlier
refresh already ran.
Add a cross-worker delivery test for the compose stack, and document change
delivery per protocol era.
Refs #6571
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…and update GatewayCreate and GatewayUpdate accept refresh_interval_seconds, but the service never wrote it to the gateways row. The scheduled auto-refresh then used GATEWAY_AUTO_REFRESH_INTERVAL for every server. Write the value on both registration paths. On update, write it only when the payload sets the field; an explicit null clears it back to the global default. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…ebounced list_changed An event that arrived inside the debounce window after the earlier refresh had run was counted as debounced and dropped. A second upstream change seconds after the first then stayed invisible until the next poll, relisten, or manual refresh. Schedule one trailing refresh per gateway at the end of the window. Later events merge their flags into it. It goes through the same queue as an immediate refresh. Shutdown cancels scheduled trailing refreshes. Update the test that pinned the drop to assert the trailing refresh. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Drop the strict xfails now that refresh_interval_seconds persists and debounced listener events get a trailing refresh. The polling fixture uses the per-server refresh_interval_seconds=60 alone. Listener tests no longer wait out the debounce window, so the trailing refresh path runs live. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…ir owner When two refreshes for one gateway waited in the queue past the debounce window, executing the older one removed the newer one's flag entry. A later trailing task then overwrote the slot of an earlier one. The earlier task's cleanup removed the newer task, so two trailing refreshes queued and shutdown could not cancel the second. Remove a pending entry only when it belongs to the refresh being executed. A trailing task removes its slot only when the slot still holds that task. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…t once Reset the listener backoff only after a stream stayed up for BACKOFF_MAX_SECONDS. A server that acks and closes at once now gets re-listened at 5 s, 10 s, 20 s and so on, not every 5 s with a full refresh. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Reflection sync (register and reflect), a scoping update and a delete with tools now invalidate the registry tool cache after the commit. Before, tool lists stayed stale up to the cache TTL. set_service_state writes only the service row, and tool lists do not read it, so it keeps no invalidation. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
… scope Start the modern listener service after start_primary_worker_elector(), so the first reconcile with the redis backend sees a running elector. Shutdown still stops the listener before the elector. Log a WARNING when listeners are on, CACHE_TYPE=redis, and PRIMARY_WORKER_ELECTION_BACKEND=filelock, because each host then runs its own listener. Document the primary-worker scope in the config field, the Helm values comment, and the Helm schema. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Assert at least one tools/list_changed per create, because a worker's Redis echo can add a duplicate by design. Set trust_env=False on the test's httpx clients so a host proxy cannot reroute them. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
The listen stream lifetime is not a secret. Move it from secret to config in values.yaml, and move its schema entry with it. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Remove the comment above the registry-cache invalidation in server creation. The code states the same thing. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
unregister_gateway() now cancels and removes the gateway's trailing refresh task and drops its pending refresh flags. The trailing task's owner check sees the entry gone and leaves other gateways' entries alone. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Rewrite the echo-suppression sentence in active voice to follow the Agent Prose rules. Refs #6571 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
…ymous callers One principal, or all anonymous callers when MCP_REQUIRE_AUTH=false, could fill the SDK's per-worker stream cap. Add MCP_LISTEN_MAX_STREAMS_PER_PRINCIPAL (default 32) and MCP_LISTEN_MAX_ANONYMOUS_STREAMS (default 256, 0 refuses anonymous streams). Refuse the extra stream with "Subscription limit reached". Claim the slot after the scope check and release it in a finally block. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
A user context with an empty email now uses the anonymous listen cap, the same as a context with no email. Release stays symmetric. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Open anonymous and per-principal streams up to the caps on a dedicated gateway. Check that a stream past a cap gets -32603 and that a full cap does not block another principal. Record both caps in the CHANGELOG. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
2d7da43 to
28857e9
Compare
|
Thanks for the review. Both points are on the stream lifecycle, so here is where each one stands. 1. One caller can fill the 1024-stream listen cap: fixed.
2. Events carry no audience: I propose to keep this as the documented decision D1.
If you see the timing signal as more serious than D1 describes, let me know and we can look at it again. |
…lane cf-integration 0.6.1 checks that a 2026-07-28 listen stream gets tools and prompts list_changed after the upstream fixture mutates its lists. This branch now serves subscriptions/listen, so the built-in data plane reaches these SHOULD checks. The gateway learns of upstream changes through refresh, and GATEWAY_MODERN_LISTENERS_ENABLED stays false by default (D2, D3), so the event does not arrive inside the check window. Record both warnings for the legacy and modern server eras. The fixture-direct lane passes both checks. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
gandhipratik203
left a comment
There was a problem hiding this comment.
Thanks for the changes! LGTM.
|
Closing this PR. As requested, I split the work per sub-issue of #6571:
Thanks @gandhipratik203 for the review here. Both caps from that discussion are in #7414. |
Pull Request
🔗 Related Issue
Closes #6571
📝 Summary
This PR makes ContextForge speak MCP 2026-07-28
subscriptions/listen(SEP-2575) on both sides and keeps legacy behavior unchanged:subscriptions/listenfrom modern clients. It sendsnotifications/{tools,prompts,resources}/list_changedwhen its own catalog changes, on every worker.Why this needed code (and not an SDK bump)
The MCP Python SDK 2.x ships the building blocks:
mcp.server.subscriptions.ListenHandlerandSubscriptionBus. It registers them automatically only for the high-levelMCPServer. ContextForge builds its endpoint on the low-levelServer(ContextForgeMCPServer(Server[Any])), whereon_subscriptions_listendefaults toNone. Before this PR:-32601forsubscriptions/listen;listChanged: false(the SDK derives the flag from whethersubscriptions/listenis registered).I checked SDK 2.2.0 (installed) and 2.3.0 (latest): both behave the same way, so an upgrade does not help. The gateway must also publish events when its catalog changes, and fan them out across workers. No SDK can do that for us.
✅ Acceptance criteria coverage
subscriptions/listenfrom modern clients and send events when the gateway's own catalog changes-32601,listChanged: false/mcpand/servers/{id}/mcp, and the missing invalidations closedClientnegotiates either era), never proven; no jitter or backoffMCPServerupstream on 2026-07-28, plus jitter and failed-refresh backoff.env.example, Helm and docs, live proof. The default staysfalse-32601; legacy capabilities pinned byte-identical; refresh never later than today;refresh_interval_secondsnow persisted🏗️ Architecture
End-to-end change flow
flowchart LR subgraph Mutations A[Admin CRUD / import / gRPC] --> I U[Upstream refresh<br/>scheduled · manual · listener] --> I end I["RegistryCache.invalidate_*()"] -->|local, before Redis| N I -->|"Redis mcpgw:cache:invalidate<br/>registry:<type>"| S[CacheInvalidationSubscriber<br/>on peer workers] S --> N["notify_catalog_change()"] N --> C[Coalesce per kind, 0.3 s] C --> B[InMemorySubscriptionBus<br/>one per worker] B --> L[SDK ListenHandler] W[ContextForge wrapper:<br/>era gate · permission narrowing · lifetime] --> L L -->|SSE: ack, then list_changed| M[Modern client]Server side in detail
mcpgateway/cache/catalog_change_bus.py(new).notify_catalog_change(cache_type)maps invalidation types to SDK events:toolsToolsListChangedpromptsPromptsListChangedresourcesResourcesListChangedserversagents,gateways,catalog, …)It is synchronous and never raises. It does nothing when there is no running loop or the type is unmapped.
It coalesces per kind and per loop in a fixed 0.3 s window, and keeps a strong reference to each publish task.
It imports only the stdlib and the SDK, so
registry_cachecan import it without a cycle.mcpgateway/cache/registry_cache.py.RegistryCache.invalidate()emits right after the in-memory clear and before the Redis block. Delivery on the local worker therefore needs neither Redis nor a successful SCAN.CacheInvalidationSubscriber._process_invalidation()emits onregistry:<type>messages from peer workers.invalidate_*call sites now publish events with no change at the call site._adapt_subscriptions_listeninmcpgateway/transports/streamablehttp_transport.py. It runs these steps in order:-32601, so legacy sessions see exactly today's response._resolve_catalog_scope()resolves server, headers and user context once. WhenMCP_REQUIRE_AUTH=falseit also runs theoauth_enabledcheck once._token_allows_catalog_read()grants the matching list permission (tools.read,prompts.read,resources.read). The handler builds a freshSubscriptionFilter, soresourceSubscriptionsand unknown or extension keys can never reach the SDK.PermissionError(_ACCESS_DENIED_MSG)astools/list.ListenHandler, which owns the ack, subscription-ID stamping, buffering and the per-worker 1024-stream cap. No DB session is held while it runs.anyio.move_on_after(deadline). The deadline is the earlier of tokenexpandMCP_LISTEN_MAX_LIFETIME_SECONDS, with a 5 s floor. When it fires, the handler returnsSubscriptionsListenResult(_meta={subscriptionId}), which is the spec's graceful closure. A client disconnect still propagates as a cancellation._list_catalog_pagenow calls the same two helpers, andLIST_PERMISSIONSmaps every*/listmethod exactly. The old.get(method, "resources.read")fallback is gone, so an unexpected method raises instead of being authorized as a resources read.ContextForgeMCPServer.get_capabilitiesclearsresources.subscribefor modern versions, next to its existing modern-onlyloggingremoval. This also reachesserver/discover. The SDK builds new capability objects on every call, so nothing leaks between requests./rpc(mcpgateway/main.py) answerssubscriptions/listenwith-32601before the tool-name fallback.Client side in detail
mcpgateway/services/gateway_service.py):_jittered_tick()sleepsinterval × (1 − 0.1 × U[0,1])between maintenance cycles.refresh_interval × 0.9._refresh_backoff_seconds()and_refresh_failuresskip a gateway after a failed scheduled refresh formin(cap, tick × 2^failures) × U[0.5, 1]. The cap is the gateway's effective refresh interval, and success resets the count.mcpgateway/services/modern_listener_service.py):_reconcile()starts listeners only whenis_primary_worker()is true, and cancels them all when that flips to false._backoff_with_jitter()applies after both errors and graceful closes. The base backoff resets only after a stream stayed up forBACKOFF_MAX_SECONDS.notify_list_changedonce per kind.mcpgateway/main.py):start_primary_worker_elector().CACHE_TYPE=redisand thefilelockelection backend.Pre-existing bug fixes
refresh_interval_secondswas never persisted.GatewayCreateandGatewayUpdateaccepted it, but nothing inmcpgateway/wrote it to the row. Both create paths now persist it. Update writes it only when the payload sets it (model_fields_set), and an explicitnullclears it.list_changedwas dropped (mcpgateway/services/notification_service.py).last_enqueued + debounce. Later events merge their flags into it, it goes through the same queue and rate limiting as a normal refresh, and shutdown andunregister_gatewaycancel it._pending_refresh_flagsand_trailing_refresh_taskscheck entry identity, so a queue backlog cannot leave an untracked second trailing task.🧭 Decisions and why
The decisions below come from the issue, the 2026-07-28 spec (re-fetched and checked line by line on 2026-10-19), the SDK source and the existing code.
D1 — Every listener gets every change kind it may read; no per-virtual-server filtering
basic/patterns/subscriptions), and says servers declaringlistChanged"SHOULD send a notification" (server/tools). Nothing requires scopinglist_changedto the caller's visible set.*/list, which already returnscacheScope: "private".ToolsListChanged, …) have no audience field, and invalidation messages carry no scope./servers/{id}/mcplistener may also refetch when nothing in its server changed. Per-server delivery can follow if this matters in practice.D2 — Redis carries events across workers; without Redis, same-worker best effort
.env.examplecallsCACHE_TYPE=database"good for single-node" andredisthe mode "for multi-node deployments". Registry-cache coherence, auth-cache invalidation, session affinity and leader election already depend on it. Helm and docker-compose default toCACHE_TYPE=redis.ttlMs: "TTL is a freshness hint, not a guarantee" (server/utilities/caching), and the gateway already setsttlMson every list.mcpgw:cache:invalidatechannel already reaches every worker, so no custom RedisSubscriptionBusis needed.CACHE_TYPEinconfiguration.md.D3 —
GATEWAY_MODERN_LISTENERS_ENABLEDstaysfalsettlMsis the baseline.AGENTS.mdblocking criterion 4. It would also open long-lived outbound connections to every eligible upstream, including ones behind idle-timeout proxies.COULD.D4 — The listen permission gate is the matching
*/listgate, not strictertools/listwould not. Sharing one helper means the two gates cannot drift apart.D5 — A stream ends at token
exporMCP_LISTEN_MAX_LIFETIME_SECONDS(default 3600), with a 5 s floorexpor a lifetime cap bounds that window without a per-stream revocation watcher. The client re-listens and re-authenticates.exp, or withexp: None(basic or proxy auth,MCP_REQUIRE_AUTH=false), get the max lifetime.subscriptions/listenrequest with a completion result before closing the stream"). Nonotifications/cancelledis sent: the cancellation page contradicts graceful closure (spec issue modelcontextprotocol#3348), and the schema limits server-sent cancel to stdio.D6 — Subtractive jitter on health-loop ticks, a 0.9 × throttle, and capped backoff on failed refresh
0.9 × R.D7 —
<category>.*token scopes and the HTTP 403 mapping are a follow-up_check_scoped_permissiondoes not honor<category>.*scopes, whereastoken_scope_grants()(theAGENTS.mdsingle policy point) does.-32603with HTTP 200 (the SDK maps a rawPermissionErrorthat way). The spec wants HTTP 403 witherror="insufficient_scope".*/listbehavior for existing tokens, so they belong in their own PR. The scope gap fails closed: atools.*token is refused, never over-granted. Listen deliberately matchestools/listtoday.D8 — Listen streams close abruptly on shutdown
ListenHandler.close()in the shutdown path never runs while streams are open.GET /mcpstreams already behave this way, and D5 bounds every stream anyway.D9 — Omit
resources.subscribeand dropresourceSubscriptionson 2026-07-28resources.subscribeto "resource-specific update notifications for resources requested through subscriptions/listen" (server/resources).basic/patterns/subscriptions), and the schema says the ack "Only includes notification types the server actually supports".resources/updated, while the SDK would otherwise advertisesubscribe: trueand echoresourceSubscriptions.resources/subscribeis untouched.resourceSubscriptionsgets an ack with an empty filter, not an error.Implementation decisions made during the build
RegistryCache.invalidate(), not in each servicelist_changed, which is harmless (idempotent refetch) and self-heals a stale L1 readis_primary_worker()instead of a new Redis/FileLock holderCACHE_TYPE=redisbut the defaultfilelockelection backend, the listener runs once per pod, not once per cluster. A startup WARNING, the Helm comment and the docs tell operators to setPRIMARY_WORKER_ELECTION_BACKEND=redis0.5×–1.5×on the listenerBACKOFF_MAX_SECONDSserverstools,resourcesandpromptsinvalidation already fires all threelist_changedkinds, and listeners refetch their scoped listset_service_statedoes not invalidategrpc_services.enabled/updated_at; no tool row changes, and tool lists never read gRPC service stateMCP_LISTEN_MAX_LIFETIME_SECONDSis a string invalues.schema.jsonFieldenforces 60–86400 at startuphelm installsubscriptions/listento_MCP_KNOWN_REQUEST_METHODS-32601for a legacy session-less probe into the SDK's-32600 Missing session IDRATE_LIMITING_ENABLED=falseInvalidation audit
Every create, update, delete and toggle path in the tool, prompt, resource, server, gateway, import, A2A and gRPC services was checked. Every new call runs after a successful
db.commit(), and a failed commit never invalidates.ResourceService.register_resourceinvalidate_resourcesResourceService.register_resources_bulkPromptService.register_prompts_bulkTemplateValidationErrorwhen an earlier chunk committed)ServerService.register_serverinvalidate_serversGatewayService.update_gateway(both branches)invalidate_gatewaysonlyGatewayService.delete_gateway(async lifecycle and hard delete)GatewayService.transfer_gateway_ownershipImportService._assign_imported_items_to_teamGrpcServiceregister, update, delete and reflection syncinvalidate_toolsset_gateway_state, the upstream refresh (scheduled, manual, listener-triggered), A2A toggle🔒 Security
/mcpand/servers/{id}/mcprequest, of any era, passesstreamable_http_authinMCPPathRewriteMiddleware. Then come theservers.usecheck (403) and the server-existence check (404), all before the SDK sees the body. Modern requests carry noMcp-Session-Id, so the 30 s session-affinity forward to/rpcnever applies to them.*/list(D4). Identity comes only from the server-side user context set by the auth middleware; nothing inparamsaffects authorization.*/list.MCP_REQUIRE_AUTH=false. An unauthenticated caller can listen with public-only scope, except onoauth_enabledvirtual servers, which return 401 (security invariant).MCP_LISTEN_MAX_STREAMS_PER_PRINCIPAL, default 32) and for all anonymous callers together (MCP_LISTEN_MAX_ANONYMOUS_STREAMS, default 256;0refuses anonymous streams). A stream past a cap gets-32603Subscription limit reached.servers.useon a server-scoped path → 403tools/listtools/listoauth_enabledserver unauthenticated withMCP_REQUIRE_AUTH=false→ 401-32601🔁 Compatibility and contract changes
MCP_LISTEN_MAX_LIFETIME_SECONDS(default3600, 60–86400).env.example, Helm values and schema,configuration.md, CHANGELOG AddedMCP_LISTEN_MAX_STREAMS_PER_PRINCIPAL(default32, 1–1024) andMCP_LISTEN_MAX_ANONYMOUS_STREAMS(default256, 0–1024).env.example, Helm values and schema,configuration.md, CHANGELOG AddedGATEWAY_MODERN_LISTENERS_ENABLEDdocumented (defaultfalse, unchanged); runs on the primary worker.env.example, Helm,configuration.md, CHANGELOG Changed, startup WARNINGlistChanged: true;resources.subscribeomittedrefresh_interval_secondsnow persisted; gateways that set it refresh on that schedule for the first timelist_changedgets one trailing refresh instead of being dropped-32601,JSON_RESPONSE_ENABLED(the SDK always serves listen as SSE), the DB schema (no Alembic migration) and the SDK version.Deployment notes
CACHE_TYPE=redis(the Helm and compose default) so listen events reach every worker.GATEWAY_MODERN_LISTENERS_ENABLEDwith several replicas: also setPRIMARY_WORKER_ELECTION_BACKEND=redisto get one listener per cluster rather than one per pod./mcphasproxy_read_timeout 1handproxy_buffering off)./mcp, it would forward listen to/rpcwith a 30 s timeout, so listen would need to bypass it. The compose stack no longer starts it (chore: remove Rust MCP config surface (issue #5819) #7255).🧪 Verification
make ruffmake interrogatemake pylintmcpgateway/middleware/rate_limit_middleware.py:25, a file this PR does not touch (it also fails onmain)make testtest_auth.py::TestCookieOnlySessionSmoke::test_refresh_with_cookie_only_without_csrf_rejectedalso fails onmain(a211ab7d8). The twotest_config.pyCSRF-warning tests pass alone on this branch and onmain, so they depend on xdist test orderuv run pytest <7 touched files>uv run pytest tests/live_gateway/mcp/test_subscriptions_listen.py -k "not cross_worker"MCPServerupstream subprocesses)test_listen_cross_worker_deliverymake coverage diff-cover,make test-e2e,make detect-secrets-scanhelm lint charts/mcp-stackWhat the live proofs cover (
tests/live_gateway/mcp/test_subscriptions_listen.py)/mcpand on/servers/{id}/mcp.resourceSubscriptions, andserver/discoveromitsresources.subscribe.-32601.AUTO_REFRESH_SERVERS=falsewithin seconds, and the listener-triggered refresh reaches a downstream listen stream.PUT /gateways/{id}storesrefresh_interval_seconds.Review history
resources.subscribe, jitter that did nothing at defaults, the denial wire shape, and the close edge cases.📁 Files
mcpgateway/cache/catalog_change_bus.py,tests/unit/mcpgateway/cache/test_catalog_change_bus.py,tests/live_gateway/mcp/test_subscriptions_listen.pymcpgateway/transports/streamablehttp_transport.py,mcpgateway/cache/registry_cache.py,mcpgateway/main.py(/rpc, startup order, warning)mcpgateway/services/{resource,prompt,server,gateway,import,grpc}_service.pymcpgateway/services/gateway_service.pymcpgateway/services/notification_service.pymcpgateway/services/modern_listener_service.pymcpgateway/config.py,.env.example,charts/mcp-stack/values.yaml,charts/mcp-stack/values.schema.json,docs/docs/manage/configuration.md,docs/docs/architecture/get-stream-quickstart.md(new "change delivery per era" section),CHANGELOG.md36 files changed, about 2.8k insertions, most of them tests.
📏 Reviewability
triage🏷️ Type of Change
✅ Checklist
make black isort pre-commit)📓 Notes
Known limits
ttlMsremains the freshness fallback.expor the max lifetime (D5).success: False) gets no backoff, as before.Follow-ups (not in this PR)
<category>.*scopes in the streamable HTTP list gate, and map catalog denials on the 2026-07-28 wire to HTTP 403insufficient_scope.GATEWAY_MODERN_LISTENERS_ENABLEDtotruein a later release (D3).Commit history
Commit
5a2336125introduced a dedicated holder lock, andb37ff1736replaced it with primary-worker election. Squash-merge keeps the history clean.Spec references
cancellation.mdxandsubscriptions.mdxdisagree on how a server ends asubscriptions/listenstream modelcontextprotocol/modelcontextprotocol#3348