Skip to content

feat(binding-mcp): add resources/subscribe, resources/unsubscribe pass-through - #2227

Merged
jfallows merged 12 commits into
developfrom
claude/mcp-binding-implementation-7bgf9h
Jul 31, 2026
Merged

feat(binding-mcp): add resources/subscribe, resources/unsubscribe pass-through#2227
jfallows merged 12 commits into
developfrom
claude/mcp-binding-implementation-7bgf9h

Conversation

@jfallows

@jfallows jfallows commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Description

Implements #2220: pass-through support for MCP resources/subscribe,
resources/unsubscribe, and notifications/resources/updated across all
three mcp binding kinds (server, client, proxy), following the
existing pattern used for other mcp-specific streams (e.g.
resources/read).

Targets the 2025-11-25 MCP spec (the version this binding currently
implements). Support for the newer 2026-07-28 stateless spec (which
replaces subscribe/unsubscribe with a unified subscriptions/listen) and
version-mapping in the proxy's one-to-many toolkit case are explicitly
deferred to future work.

Changes

  • mcp.idl: new McpResourcesSubscribeBeginEx / McpResourcesUnsubscribeBeginEx
    / McpResourcesUpdatedFlushEx extension types, and a SERVER_RESOURCES_SUBSCRIBE
    capability bit.
  • McpFunctions: builder/matcher support for the new extension types, plus
    direct McpFunctionsTest unit-test coverage for each (see "Verification").
  • New spec .rpt scripts (network + application, paired client/server) and
    NetworkIT/ApplicationIT methods, written before the implementation per
    this repo's test-first discipline.
  • McpServerFactory: decodes resources/subscribe/unsubscribe, encodes the
    subscribe capability in the initialize response only when the
    downstream binding actually advertises it, and relays
    notifications/resources/updated as an SSE event.
  • McpClientFactory: encodes resources/subscribe/unsubscribe as JSON-RPC
    to the real upstream MCP server, parses the resources.subscribe
    capability from its initialize response, and relays
    notifications/resources/updated back as an application-layer flush.
  • McpProxyFactory / new McpProxyResourcesSubscribeFactory /
    McpProxyResourcesUnsubscribeFactory: routes the two new request kinds to
    the correct toolkit route, same as resources/read.
  • McpProxyLifecycleFactory: re-prefixes the URI on a relayed
    notifications/resources/updated (<toolkit>+<uri>, same convention as
    every other aggregated resource), and now records each south route's
    real server capabilities (learned from that route's own upstream
    handshake) instead of only reflecting a static, route-config-declared
    capability.
  • McpBindingConfig: aggregates per-route real capabilities via a
    boxing-free Long2LongHashMap, merged with each route's static
    capabilities when computing what to advertise for a given caller.
  • examples/mcp.proxy: new resource-subscribe-client headless demo client
    exercising the full round-trip against the everything reference server
    (which implements subscribe/unsubscribe/updated specifically to support
    test clients), wired into compose.yaml, the README, and
    .github/test.sh.

Verification

./mvnw clean verify -pl runtime/binding-mcp (279 tests) and
./mvnw clean verify -pl specs/binding-mcp.spec (248 tests) both pass,
including all jacoco coverage gates. A full top-level build
(./mvnw clean install -DskipITs -DskipTests) also succeeds.

(CI initially caught a real gap here: the new McpFunctions builder/matcher
classes were only exercised indirectly through .rpt scripts, which k3po
runs in a process jacoco doesn't instrument. Fixed by adding direct
McpFunctionsTest unit tests mirroring the existing resourcesRead /
resourcesListChanged coverage pattern.)

The examples/mcp.proxy end-to-end demo is being verified live against a
locally built Docker image in this environment; will report back or push a
follow-up fix if that surfaces anything.

Fixes #2220

claude added 12 commits July 30, 2026 23:12
…s-through

Adds pass-through support for MCP resources/subscribe, resources/unsubscribe
requests and notifications/resources/updated notifications across all three
mcp binding kinds (server, client, proxy), per the 2025-11-25 MCP spec.

- mcp.idl: new SERVER_RESOURCES_SUBSCRIBE capability bit, McpResourcesSubscribeBeginEx/
  McpResourcesUnsubscribeBeginEx union cases, McpResourcesUpdatedFlushEx union case
- server kind (McpServerFactory): dispatch resources/subscribe|unsubscribe, relay
  notifications/resources/updated over SSE, advertise resources.subscribe in the
  initialize response only when the app/backend actually declares the capability
- client kind (McpClientFactory): forward resources/subscribe|unsubscribe to the
  upstream server, parse the upstream's resources.subscribe capability during
  initialize, relay upstream notifications/resources/updated back up the pipeline
- proxy kind: route subscribe/unsubscribe to the correct upstream toolkit (same
  URI-prefix mechanism as resources/read), re-prefix the uri on relayed
  notifications/resources/updated, and capture each toolkit's real negotiated
  capabilities (previously discarded) so resources.subscribe is aggregated from
  what upstreams actually support rather than the static route-config bits alone
- specs/binding-mcp.spec: new application/network scenario scripts for subscribe,
  unsubscribe, unsubscribe-of-unknown-uri (network-level, documents the wire
  behavior), and notifications/resources/updated, wired into NetworkIT/
  ApplicationIT and the runtime McpServerIT/McpClientIT/McpProxyIT suites

Note: this sandbox has no credentials for maven.packages.aklivity.io, so
flyweight-maven-plugin could not be resolved to regenerate the IDL-derived
Java sources, and none of this change could be locally compiled or run.
Every edit was written by direct, careful mirroring of the existing analogous
code path (resources/read, notifications/*/list_changed) rather than
verified by a build. A CI run and careful review are needed before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157qpppRcorMgMSxTddQV9a
…tion

Now that this sandbox's flyweight-maven-plugin resolution is fixed, the
previous commit's implementation could actually be compiled and tested
against a live engine. This fixes what that first real build turned up:

- McpServerFactory.java: missing import for McpResourcesUpdatedFlushExFW
  (compile error)
- McpBindingConfig.java: Long2IntHashMap doesn't exist in the pinned Agrona
  version; switched to Long2ObjectHashMap<Integer> with explicit boxing to
  avoid an overload-resolution ambiguity between Agrona's long/Long put()
  overloads
- McpClientFactory.java: the newStream() dispatch never set
  request.contentLength/timeout for KIND_RESOURCES_SUBSCRIBE/UNSUBSCRIBE,
  leaving contentLength at its -1 default and causing
  doEncodeStreamedRequestEnd() to compute a negative padding length,
  crashing with IndexOutOfBoundsException on every subscribe/unsubscribe
  request from the client kind
- application/{resources.subscribe,resources.unsubscribe,
  lifecycle.notify.resources.updated}/server.rpt: the app-side lifecycle
  begin never declared the SERVER_RESOURCES_SUBSCRIBE capability, so the
  network-side scripts' asserted "subscribe":true in the initialize
  response never matched what the server kind actually emitted

Verified: `./mvnw clean verify -pl runtime/binding-mcp` is a clean
BUILD SUCCESS (279 tests, all coverage checks met). `./mvnw clean verify
-pl specs/binding-mcp.spec` passes all 248 tests but currently fails its
coverage gate — jacoco reports 0% instruction coverage on the new
McpFunctions builder/matcher inner classes for subscribe/unsubscribe/
updated despite the protocol-level tests that exercise them passing,
which looks like a coverage-attribution quirk rather than a real gap;
flagging for a maintainer to confirm rather than asserting a cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157qpppRcorMgMSxTddQV9a
Long2ObjectHashMap<Integer> boxed every capability bitmask read/write.
Agrona has a real primitive fit here: Long2LongHashMap, storing the int
bitmask widened to long, with get()/put() fully primitive overloads.

Hit a real edge case along the way: Long2LongHashMap's missingValue
sentinel (0L here, matching "no capabilities recorded" for an absent
route) cannot itself be put() — throws IllegalArgumentException. Since
"absent" and "merged == 0" already mean the same thing for a bitmask
that defaults to 0, recordServerCapabilities() now just skips the put
when the merged value would be the sentinel, verified against the full
runtime/binding-mcp test suite (279 tests, all coverage checks met).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157qpppRcorMgMSxTddQV9a
Exercises resources/subscribe, resources/unsubscribe, and the relayed
notifications/resources/updated (#2220) against the
everything reference server through mcp(server) -> mcp(proxy) ->
mcp(client). Adds a headless demo client, a compose service to run it
on demand, a README walkthrough, and a smoke-test assertion in
.github/test.sh.

Not yet verified against a live docker compose run in this environment;
verification is in progress.
…s directly

CI failed the jacoco coverage gate (0.86 vs 0.96 required, 6 classes at
0%) on binding-mcp.spec. Root cause: the new resourcesSubscribe/
resourcesUnsubscribe/resourcesUpdated builder and matcher classes were
only exercised indirectly via .rpt scripts, which k3po runs in a
separate process the jacoco agent never instruments -- unlike sibling
classes (e.g. resourcesRead), which are also covered by a direct
McpFunctionsTest unit test that runs under Surefire/jacoco. Adds the
missing direct unit tests, mirroring the existing resourcesRead /
resourcesListChanged pattern.
….json

Matches the checked-in lockfile convention used by the sibling
tools-list-client and url-elicit demo clients.
…-implementation-7bgf9h

# Conflicts:
#	examples/mcp.proxy/.github/test.sh
…-id code, for resources/updated

notifications/resources/updated was being re-prefixed with
McpAggregateRoute.prefix() -- a short CRC32C-derived code used only to
disambiguate resumable event/correlation ids across aggregated toolkits
(e.g. "S" for a toolkit named "bluesky") -- instead of the toolkit's
real "<toolkit>+" URI prefix that resources/list and resources/read
already use. A caller subscribed to "everything+demo://..." therefore
received updates for "Vdemo://..." (or similar), silently mismatching
its own subscription.

Found via a live end-to-end run against examples/mcp.proxy: capability
negotiation and the subscribe/toggle calls all succeeded, but the
relayed notification's uri never matched what was subscribed. Confirmed
directly against the real "everything" reference server (bypassing
Zilla) that it sends a clean, unprefixed uri, isolating the corruption
to the proxy's own relay path.

McpBindingConfig gains routeResourcesPrefix(routedId), a lookup by
McpRouteConfig.id mirroring the existing routeCacheCredentials
pattern, returning the same McpConditionMatcher-derived prefix
resources/read already relies on. McpProxyLifecycleFactory stores it
as a new resourcesPrefix field, separate from the existing prefix
field (which correctly keeps using the short aggregate code for
elicitation correlation-id prefixing -- that usage was never wrong).

Existing single-route notify tests never caught this because
computeRouteByPrefix only activates aggregation once routes.size() > 1,
and the two prefix schemes are indistinguishable with only one
configured route. Adds a regression scenario
(lifecycle.notify.resources.updated.toolkit.multi) against
proxy.toolkit.multi.yaml asserting the relayed uri carries the real
toolkit prefix; confirmed it fails against the prior code (uri
prefixed "S" instead of "bluesky+") before the fix and passes after.
…SE events decode

McpClientFactory's HttpEventStream (the persistent GET SSE listener used to
receive server-pushed notifications, including resources/updated) set
replyAck once from the initial BEGIN and never advanced it afterward. Since
its receive window is a fixed replyAck+decodeMax ceiling, any sufficiently
long-lived listener eventually exceeds it purely from cumulative bytes --
independent of any individual message's content -- and self-resets via
cleanupNet(), tearing down the listener and losing whatever notification
triggered the overflow.

Add flushNetWindow to HttpEventStream, mirroring the sibling HttpStream's
existing correct pattern, and call it after each decode so the window keeps
pace with consumed bytes instead of starving.

Regression test: McpClientIT.shouldNotifyResourcesUpdatedRepeated drives six
resources/updated notifications over one GET listener with a small configured
buffer slot capacity, reproducing the starvation deterministically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157qpppRcorMgMSxTddQV9a
The synthetic k3po scenario added in 7628b0e to regress the GET-listener
window-starvation fix proved unreliable to construct: shrinking the engine's
global buffer slot capacity small enough to trip the bug within a test also
destabilizes unrelated flow-control layers beneath the mcp binding (observed
as hangs unrelated to the fix itself, confirmed by CI). The production fix in
McpClientFactory (flushNetWindow on HttpEventStream) stays -- it was verified
directly against the real aklivity/server-everything reference server via a
live zilla dump packet capture during the original investigation, and the
full binding-mcp verify suite (282 tests) passes cleanly with it applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157qpppRcorMgMSxTddQV9a
…ent SSE decode

A string or number lexeme whose bytes fill the input window before completing
is delivered by JsonParserEx as repeated fragments (deferredBytes() true)
rather than a single complete event. The mcp client's SSE JSON-RPC decoders
were treating the first fragment as the whole value, so a long value (e.g. a
notified resource uri) split across a network read boundary was truncated,
corrupting the notification and triggering a decode error downstream.

Add a shared accumulateJsonRpcStringValue helper, backed by a new sseJsonValue
StringBuilder (kept independent of the existing sseSmallValue field, which is
never cleared after a completed id:/retry: read and would otherwise leak
stale bytes into accumulated values), and use it in every JSON-RPC decoder
that reads a scalar: version, id, method, and the params token/message/
elicitationId/url/mode/uri fields.

Adds a regression IT (shouldNotifyResourcesUpdatedFragmented) with a small
buffer slot capacity to force a long resource uri to split across a decode
window, plus the paired client/server spec scripts and ApplicationIT
peer-consistency check.
…edging the decoder

decodeJsonRpcParamsNext's default case (an unrecognized params key) looped
back to itself by reassigning the same decoder field reference, which left
decodeNet's "previous != decoder" progress check unable to tell that
anything happened. The outer decode loop exited immediately, buffering the
still-unconsumed value in the decode slot. The next onNetData call resumed
decodeJsonRpcParamsNext directly on that orphaned value token, which the
switch only expects to see KEY_NAME/END_OBJECT for, so it raised a parse
error and left the stream unable to make further progress -- silently
dropping every later event on the same SSE connection, including
notifications/resources/updated. This reproduced live against the
`aklivity/server-everything` reference server, whose notifications/message
event carries params keys ("level", "data") this decoder didn't recognize,
sent immediately before any resources/updated notification on the same
GET stream.

Add a dedicated decodeJsonRpcParamsSkipValue decoder, using the same
depth-tracked skip pattern as decodeJsonRpcSkipObject, that consumes exactly
one JSON value of any shape (scalar, object, or array, including a scalar
that itself spans a decode window) before returning to
decodeJsonRpcParamsNext -- as a distinct decoder reference, it lets the
outer loop's progress check register the transition correctly.

Confirmed against the live mcp.proxy example end-to-end (resources/subscribe
+ toggle-subscriber-updates against the real reference server), which had
been failing in CI even after the prior JSON-fragmentation fix.
@jfallows
jfallows merged commit fe05c34 into develop Jul 31, 2026
42 checks passed
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.

binding-mcp: support resources/subscribe pass-through

2 participants