Skip to content

Refactor MCP HTTP proxy into kind-specific implementations#1977

Open
jfallows wants to merge 28 commits into
developfrom
claude/zilla-index-issue-1976-ppliuw
Open

Refactor MCP HTTP proxy into kind-specific implementations#1977
jfallows wants to merge 28 commits into
developfrom
claude/zilla-index-issue-1976-ppliuw

Conversation

@jfallows

@jfallows jfallows commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Description

Refactor the monolithic McpProxy class into a hierarchy of kind-specific proxy implementations to improve code clarity and maintainability.

Changes

Architecture:

  • Convert McpProxy from a concrete class to an abstract base class containing shared state and lifecycle methods
  • Create McpHttpProxy abstract subclass for HTTP-backed kinds (tools/call, resources/read) that own the HttpProxy delegate and request/response streaming machinery
  • Implement four concrete proxy classes:
    • McpToolsListProxy — handles tools/list requests (buffered response)
    • McpResourcesListProxy — handles resources/list requests (buffered response)
    • McpPromptsListProxy — handles prompts/list requests (buffered response)
    • McpToolsCallProxy — handles tools/call requests (HTTP-backed with optional streaming)
    • McpResourcesReadProxy — handles resources/read requests (HTTP-backed with optional streaming)

Refactoring details:

  • Move kind-specific request handling logic into onMcpRequest() abstract method, overridden per kind
  • Move kind-specific response mapping into onUpstreamResponse() abstract method for HTTP-backed kinds
  • Extract response streaming setup into responseBegin() and responseStreamable() methods, overridden only by McpResourcesReadProxy
  • Simplify newStream() factory method to use a switch statement that instantiates the appropriate proxy type
  • Remove conditional checks on kind and delegate throughout the codebase; behavior is now determined by the concrete class

Benefits:

  • Eliminates large conditional blocks checking kind and delegate throughout the code
  • Makes the request/response flow for each kind explicit and localized
  • Reduces parameter passing by eliminating unused fields (e.g., resource and kind from base class)
  • Improves testability by allowing kind-specific behavior to be tested in isolation
  • Clarifies which methods are overridden vs. shared across kinds

Fixes #1976

https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB

claude added 16 commits July 1, 2026 16:56
…to per-request-kind stream classes

Replace the single general-purpose McpProxy/HttpProxy pair — which branched
on the McpBeginEx kind throughout handleRequest and onUpstreamResponse — with
dedicated single-purpose stream classes, one per MCP method, mirroring the
per-method stream pattern in binding-mcp's McpClientFactory.

- abstract McpProxy: shared MCP-side stream plumbing (frame dispatch, reply
  writers, decode/encode slots, cleanup); per-kind action via onMcpRequest
- abstract McpHttpProxy: the two kinds that proxy to an upstream HTTP endpoint
  (tools/call, resources/read) — owns the paired HttpProxy, the request-shaping
  path built from route.with, and the streaming request/response machinery;
  per-kind response mapping via onUpstreamResponse
- McpToolsCallProxy, McpResourcesReadProxy extend McpHttpProxy
- McpToolsListProxy, McpResourcesListProxy, McpPromptsListProxy,
  McpPromptsGetProxy extend McpProxy
- newStream dispatches to the per-kind class; HttpProxy is generic again,
  with kind checks replaced by polymorphic hooks (responseStreamable, tool)

Behavior-preserving: all 29 McpHttpProxyIT tests pass, plus unit tests and
checkstyle. Keeps the per-side decodeSlot/encodeSlot model from #1975.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…de onMcpBegin per kind

Follow the http-kafka proxy pattern where each concrete stream owns its frame
handler rather than a shared base method delegating to an *Impl hook. The base
McpProxy.onMcpBegin handles the buffered kinds; McpHttpProxy and McpToolsCallProxy
override onMcpBegin directly. Cross-side callbacks (onUpstreamResponse,
responseBegin, responseStreamable) remain abstract/overridable on the base,
matching http-kafka's abstract onKafkaBegin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…ly/doEncodeRequestBody

The two methods that append bytes into the per-stream encodeSlot ahead of
flushReply/flushRequestStream draining them as DATA frames were named
stage/stageBytes, an invented verb with no precedent elsewhere in the
runtime tree. Follow the doEncode<Thing> shape used by binding-mqtt/binding-http
(doEncodePublishV4, doEncodeConnack, ...) for methods that assemble bytes
destined for the wire: rename to doEncodeReply (McpProxy, overloaded for
byte[] and DirectBufferEx) and doEncodeRequestBody (HttpProxy, was
stageRequestBody).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…ProxyFactory

Following an audit of the stream classes for heap allocations on the data
path (per runtime/AGENTS.md), fix two real, contained findings:

- doMcpReply(String) re-encoded the cached tools/list, resources/list,
  prompts/list JSON String into a fresh byte[] via getBytes(UTF_8) on every
  single request, despite the JSON text itself being memoized. Cache the
  encoded byte[] directly on McpHttpBindingConfig instead of the String, and
  retype doMcpReply to take byte[] and call doEncodeReply(byte[]) directly
  (no other caller used the String overload).
- Four computeIfAbsent(schema, this::newXxx) call sites reallocated a fresh
  capturing method-reference object on every call, cache hit or miss, even
  though the JsonPipeline values themselves were correctly cached. Hoist the
  four method references into reusable Function fields.

Two other candidates from the audit (doHttpBegin's per-header/per-credential
lambda captures, onHttpBegin's .asString() header matching) were confirmed
via cross-codebase grep to be the standard Zilla-wide idiom (HttpKafkaProxyFactory,
McpServerFactory, McpClientFactory, McpBindingConfig all use the same
patterns) and left unchanged. A larger rework of the request/response
templating chain (interpolate, encode, navigateBytes, queryStringFromBytes)
is a real but separate, larger follow-up, deliberately out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
Two independently-designed investigations of the request/response templating
chain (interpolate, encode, navigateBytes, queryStringFromBytes) converged
on the same low-risk subset worth fixing now, while flagging that a full
buffer-native rewrite of the whole chain carries real new risk in this
codebase (production builds run with Agrona bounds-checking disabled, so a
fixed-capacity buffer write without an explicit, tested overflow guard could
silently corrupt memory instead of failing cleanly) and only a modest,
once-per-request payoff — deferred as a separate, test-first follow-up.

- encode(String): always paid 3 allocations (StringBuilder, getBytes(UTF_8),
  final toString()) to percent-encode a URL path/query value. Add an ASCII
  fast path: pre-scan for any char >= 0x80: for pure-ASCII input (the common
  case for tool args, ids, route params), iterate charAt(i) directly through
  the same percent-encode branching, skipping the UTF-8 byte conversion
  entirely (a single-byte ASCII char and its UTF-8 byte are bit-identical,
  so output is byte-for-byte unchanged). Non-ASCII input falls through to
  the original, unmodified byte-based path.
- argReferences(route.with.headers.get(HEADER_PATH)): re-parsed a route's
  :path template into a fresh ArrayList + per-match substrings on every
  large-body-streaming tools/call stream open, despite depending only on
  immutable route config. Cache the result per route via the same
  IdentityHashMap + hoisted-Function computeIfAbsent pattern already used
  for templates/projectors/validators in this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
Audited every boolean field in McpHttpProxyFactory's stream classes against
McpHttpState and the reference McpClientFactory implementation. Mixing
mode/latch booleans alongside a state bitmask is the established Zilla
convention (McpClientFactory itself has a dozen+ such fields) — not
something to collapse into McpHttpState wholesale. But four fields turned
out to duplicate information already tracked elsewhere, set only in the
same synchronous call as the original with no independent transition:

- McpToolsCallProxy.requestEndedMcp duplicated McpHttpState.initialClosed
  (state), set on the same call in onMcpEnd.
- HttpProxy.responseEnded duplicated McpHttpState.replyClosed(state), set
  on the same call in onHttpEnd.
- HttpProxy.responseStreaming duplicated McpHttpProxy's own
  responseStreaming field one-for-one, always toggled in lockstep since
  responseBegin() sets it and HttpProxy immediately mirrors it. HttpProxy
  already reads several of the server's fields directly elsewhere
  (requestStreaming, requestProjected, responseDone) rather than keeping
  local copies — this makes responseStreaming consistent with that.
- McpToolsCallProxy.requestBegun duplicated
  McpHttpState.initialOpening(delegate.state): sendRequestBegin() calls
  delegate.doHttpBegin(...), which unconditionally sets that bit on the
  delegate; requestBegun was set true immediately after, in the same call.

13 booleans -> 9. No new state bits, no McpHttpState.java changes, no
behavior change — purely reading existing state instead of a shadow copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…large request bodies

pumpRequest's STARVED handling discarded the whole decode window
(decodeSlotOffset = 0) instead of keeping the partial trailing unit that
JsonPipeline.remaining() reports as unconsumed, corrupting any multibyte
UTF-8 character split across a DATA frame boundary in a streamed tools/call
request body. Fix it to compact via remaining(), mirroring pumpResponse's
already-correct implementation.

Also widen the streaming path to cover unknown-length request bodies
(contentLength < 0), not just ones known upfront to exceed the buffer slot,
since a missing content-length header previously fell through to the fully
buffered path and aborted once the body exceeded slot capacity.

Adds a create.pr.fragmented spec scenario that splits a 2-byte UTF-8
character exactly across two DATA frames, reproducing the corruption before
the fix and passing after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…single streaming pipeline

Remove the content-length-based split between a fully-buffered onMcpRequest
path and the incremental JsonPipeline path for tools/call requests with a
body: every route with route.with.body configured now always builds and
drives the same requestPipeline/pumpRequest machinery, regardless of size,
so a small single-frame request is just the one-transform()-call special
case of the same code a large streamed body runs through.

unsatisfiedAccessors is a purely static, route-level check, so it's now
computed once at onMcpBegin and applied on the first pumpRequest call
(deferred past window-granting, since resetting immediately would close the
stream before a client write already in flight can land).

The tool.input schema validator is composed into the same pipeline
(mirroring the validator+projector composition already used for upstream
response validation) but only when the request is guaranteed to arrive
within one bounded decode slot (contentLength known and <= slot capacity):
JsonSchema's validator implementation requires full reassembly of any
scalar value that spans multiple windows before validating it, which is
incompatible with an unbounded streamed body and a fixed-size decode slot.

pumpRequest's REJECTED handling now aborts the (possibly not-yet-opened)
upstream HTTP connection and replies with the same graceful tool error the
buffered path always used, rather than a raw reset/abort combo, since a
connection may already be in flight by the time a validation failure is
known.

Adds a create.pr.malformed.request spec scenario covering a genuinely
incomplete (not just schema-invalid) request body, which previously hit no
test coverage on this path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…SET, not a synthesized reply

Align invalid-arguments handling with how the reference binding-mcp treats
the same scenario: McpProxyItemFactory.validateArgs resets the upstream leg
with a McpResetExFW carrying JSON-RPC code -32602 ("Invalid params") and
aborts downstream, leaving the terminal, wire-owning binding (McpServerFactory)
responsible for turning that into a graceful client-facing reply. This
binding sits in the same internal-proxy-hop position, so both pumpRequest's
REJECTED handling and onMcpRequest's pre-existing validate() failure branch
now do the same doMcpReset(-32602)+abort instead of synthesizing an
isError:true tool result themselves — removing an inconsistency that
predated this session (onMcpRequest) and one introduced by it (pumpRequest).

Also drops two per-stream boolean/field additions from the previous commit
(requestRejected, unresolvedAccessor) in favor of the existing McpHttpState
bitmask: doMcpReset already transitions state to closedInitial internally,
so pumpRequest's post-loop guards read McpHttpState.initialClosed(state)
directly instead of tracking a parallel flag, and the unsatisfied-accessors
check is now a plain guard clause backed by the already-memoized
unsatisfiedAccessors(route) lookup rather than a precomputed field.

Corrects the validator-gating comment to state the real constraint precisely
(an individual value must fit within one window, not the whole request) and
files #2016 against common-json for the underlying gap,
confirmed by direct code reading: a scalar value that never fits any window
causes the validator's pipeline to stall in STARVED forever, never resolving
to REJECTED even when the final window is marked last.

Updates create.pr.invalid and create.pr.malformed.request to expect the
resulting abort instead of a graceful reply, and adds search.code.invalid to
cover the onMcpRequest branch's updated behavior, previously untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…e, remove redundant booleans, hoist allocations

Replace the requestEnd/replyComplete/responseStreaming per-stream booleans
with McpHttpState CLOSING bits (reusing bit constants already declared but
unused), matching the state-tracking convention used by McpClientFactory,
HttpKafkaProxyFactory, and GrpcKafkaProxyFactory. responseStreaming was
provably redundant with responsePipeline != null and is removed outright.

Also hoist a handful of hot-path allocations that were previously
reallocated on every call: McpHttpBindingConfig's un-hoisted
this::resolveJsonSchema method reference, the four Map.of(JsonSink.DELIVERY,
...) sink-config literals in McpHttpProxyFactory, and
unsatisfiedAccessors' unconditional ArrayList allocation (now lazily
allocated only when an unsatisfied accessor is actually found).
…directly into encodeSlot

buildToolError/buildResource/buildPromptGet and doMcpToolSuccess staged the
full reply envelope in a shared scratch buffer before a single copy into
encodeSlot. doMcpToolSuccess already proved that multiple ordered
doEncodeReply calls straight into encodeSlot work safely with no extra
DATA-frame fragmentation, so the other three builders now follow the same
shape via two new primitives (doEncodeReplyJsonString, for escaped-string
and buffer-splice writes) and a shared completeReply() tail. This removes
the replyBuffer field and its backing allocation entirely, along with the
now-unused doMcpReplyBytes.
…ResourceRoute

Replaces stream().filter().findFirst() with a plain for-loop, matching the
style already used by resolveResource/toolGuarded, removing a per-request
Stream pipeline and capturing lambda allocation on the tools/call and
resources/read hot paths.
…nce bindings

Renames onUpstreamResponse/onUpstreamAbort to onHttpResponse/onHttpAbort
(keeping the protocol prefix on abstract hooks, matching HttpKafkaProxyFactory's
onKafkaBegin), grantHttpReply to grantHttpReplyWindow, responseComplete to
completeResponse, flushRequestStream to flushRequest, and HttpProxy's server
field to mcp (avoiding collision with XxxServerFactory terminology). Also
reorders HttpProxy's methods so on* handlers precede do*/helper methods,
matching McpProxy's own ordering and the three reference bindings.

No behavior change.
… aliasing hazard

DefaultBufferPool.buffer(slot) returns one shared, mutable slotBuffer field
re-wrapped in place on every call, not a fresh view per slot. With a single
bufferPool field serving both HttpProxy's decode/encode slots and
McpProxy's, a DirectBufferEx reference obtained from one slot silently
repoints to whatever slot the next buffer() call touches, regardless of
which object holds the reference. This previously blocked eliminating the
text()/cap() String round-trip in the tools/call non-2xx error path: a
prior attempt at a zero-copy encode splice read back its own just-written
prefix bytes instead of the real upstream response body once encodeSlot's
buffer() call repointed the same wrapper backing the decode-side body
reference.

Splits the single bufferPool into decodePool/encodePool via duplicate(),
matching the exact pattern McpClientFactory already uses (decodePool =
context.bufferPool(), encodePool = context.bufferPool().duplicate()) so
concurrent access to a decode slot and an encode slot always resolves
through distinct DefaultBufferPool instances with independent slotBuffer
wrappers. With that in place, doEncodeToolError can safely splice the
upstream response body straight into encodeSlot without any intermediate
copy; text()/cap() are removed as they become unused.
jfallows pushed a commit that referenced this pull request Jul 5, 2026
Isolate this PR to the common-json fix for #2017. The McpHttpProxyFactory
bounds-safety changes will be ported onto the kind-specific proxy hierarchy
landing in #1977 instead of the current monolithic class, to avoid rework.
claude added 12 commits July 5, 2026 21:02
…s on buffered resources/read

Two proxy pass-through bugs, fixed test-first:

1. An MCP client resetting the reply direction of an in-flight tools/call
   or resources/read stream never tore down the paired upstream HTTP
   connection. onMcpReset is made overridable and McpHttpProxy now mirrors
   it to the delegate via a new HttpProxy.doHttpReset, which sends RESET
   on the delegate's own reply direction (not ABORT) -- propagating RESET
   as RESET, matching the existing ABORT-as-ABORT mirroring already used
   elsewhere in this class. Guarded the same way doHttpAbort already is,
   so it correctly no-ops if the upstream request was never opened.

2. McpResourcesReadProxy's buffered onHttpResponse never checked the
   upstream HTTP status before treating the response as successful
   content, unlike McpToolsCallProxy's equivalent method. A non-2xx
   response with a schema-satisfying body was reported to the MCP client
   as a successful resources/read. Now aborts on non-2xx, consistent with
   the existing produced<0 handling for this proxy kind. The streaming
   resources/read path has the same gap in principle but is left
   unaddressed -- responseBegin has no status available to check before
   committing to a reply -- and is flagged with a comment.

New tests: shouldAbortUpstreamWhenReplyReset, shouldAbortResourceReadWhenUpstreamStatusNotOk.

Fixing bug 1 also surfaced a pre-existing k3po script race: "read abort" in
this test harness is both a passive matcher and an active reset fallback,
so any script ending on an unsynchronized "read abort" right after writing
a request body could race the request/response delivery once onMcpReset
became consequential. Added explicit notify/await synchronization to the
two existing scenarios affected (create.pr.aborted, read.order.malformed)
alongside the two new ones.
Removes the buffered onHttpResponse path entirely: tools/call and
resources/read now open the reply and drive responsePipeline as soon as
status/content-type are known, generating directly against encodeSlot's
live buffer instead of a bounded scratch region. Structured content streams
first; tool.summary is resolved from values McpHttpResults captures as the
response passes through (a response-side counterpart to McpHttpArguments),
so the reply envelope no longer needs the whole body buffered up front.
Non-2xx tools/call responses stream back as escaped text via a dedicated
error-relay path with no size cap.

Also adds the McpServerIT/HttpClientIT peer-to-peer spec coverage for the
two new large-response scenarios, matching this repo's test-first
convention for every new spec scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
Replaces the post-hoc, unbounded raw-byte write of a tools/call success
envelope's trailer (content/isError, including the ${result.*}-interpolated
summary) with McpHttpToolResult, a JsonTransform that wraps the whole
envelope as generator-tracked events sharing structuredContent's own bounded,
resumable write path. A summary interpolating a large captured value could
previously overflow encodeSlot once structuredContent had already filled it,
since the trailer bypassed the pipeline's own backpressure entirely.

A suspended synthetic write now resumes via sink.resume(...) rather than
re-invoking sink.transform(...), avoiding the double-emission bug present in
the existing McpSchemeInjector precedent (harmless there only because its
injected content is always small).

The two remaining fixed-size suffixes (tools/call error-relay, resources/read)
now own their own bounds-safety via a small ensureEncodeRoom helper instead of
relying on the caller to reserve headroom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
…d fix common-json SUSPENDED/COMPLETED bug

McpHttpResults blindly forwarded its upstream JsonController to its downstream
sink, so a byte-preferring terminal sink's segmentable() request reached the
real parser unblocked. Once granted, the whole document streamed as one
opaque SEGMENT run and the KEY_NAME events McpHttpResults matches paths
against never fired, silently breaking tool.summary result-path capture.
Fixed by giving McpHttpResults its own downstream JsonController that
declines segmentable() while paths are configured (forwarding it only when
there is nothing to watch), forwards verbatim(), and adds matching
resume()/flush() overrides, per the same mediating-transform rule
common-json's JsonSchemaImpl.Validator already follows.

Chasing this down through the real engine (not just unit tests) surfaced a
second, independent bug in common-json: JsonSchemaImpl.Validator.verdictStatus
checked a VALID schema verdict before checking a SUSPENDED downstream status,
so a mediating stage that injects more content once a validated value closes
(e.g. McpHttpToolResult wrapping structuredContent in a larger envelope) could
have its own SUSPENDED silently reported as COMPLETED, truncating everything
still queued downstream. Fixed the precedence and added a regression test
reproducing the exact failure mode with a minimal injector stage.

Also:
- Add k3po IT coverage for three tool-response shapes that previously had no
  test coverage at all (only exercised by since-removed unit tests): a tool
  with no configured summary, an array-rooted output schema, and a
  bare-scalar-rooted output schema (ping/list_tags/count_items).
- Add the get.report.large scenario proving tool.summary interpolation of a
  captured value survives suspend/resume across the encode slot when the
  captured value is itself large.
- Remove McpHttpResultsTest.java/McpHttpToolResultTest.java: this module has
  no precedent for unit-testing transform/ classes, and per AGENTS.md this
  repo verifies streaming/protocol behavior via k3po IT specs, not JUnit.
- Correct a misleading example in AGENTS.md that showed running an *IT.java
  class via `mvnw test`, which silently skips k3po startup and produces a
  misleading "Is K3PO ready?" failure instead of a real one.

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

McpHttpArguments (request-side arg capture) had the same mediating-transform
bugs already fixed in McpHttpResults on the response side: a top-level
argument value spanning multiple decode windows was truncated instead of
accumulated, and segmentable()/verbatim()/consumed() weren't properly
intercepted and forwarded, risking the whole request going opaque before
KEY_NAME events could ever be matched. Fixed by mirroring McpHttpResults'
already-shipped shape, proven by a new echo_id tool/route whose argument
spans multiple windows.

Separately, the response-streaming rewrite regressed the no-summary
content[0].text fallback: it now emits an empty string instead of the
pre-existing behavior of stringifying structuredContent as text. Restored
via a new McpHttpResultText transform stage that mirrors the response's
canonical JSON into a bounded, dedicated buffer as it streams (abandoning
gracefully rather than overflowing on a pathologically large response),
proven by a new get_profile tool/scenario with nested object/array/boolean/
null content. Also fixes binding-mcp-openapi's create_pr scenarios, which
depend on this fallback and were failing in CI against the regression.
…-issue-1976-ppliuw

# Conflicts:
#	specs/binding-mcp-openapi.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/openapi/streams/mcp/create.pr.10k/client.rpt
#	specs/binding-mcp-openapi.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/openapi/streams/mcp/create.pr.10k/server.rpt
#	specs/binding-mcp-openapi.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/openapi/streams/mcp/create.pr/client.rpt
#	specs/binding-mcp-openapi.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/openapi/streams/mcp/create.pr/server.rpt
…ry text buffer

The streaming/buffer audit found McpHttpResultText's dedicated buffer was
the only remaining per-stream buffer beyond decode/encode slot, needed only
to reconstruct content[0].text when a tool had no summary configured.
Making summary required removes that fallback path entirely: every tool's
content[0].text is now always the interpolated summary via the existing,
lightweight McpHttpResults capture, with no separate document buffer.

binding-mcp-openapi's composite generator now defaults a tool's summary to
a literal "Call <operationId>" (computed at generation time, not a
${...} template) when the underlying OpenAPI operation has none, since
OpenAPI's summary field is itself optional.
…r.aborted

Mirrors the same notify/await synchronization already applied to
binding-mcp-http.spec's copy of this scenario earlier this session: k3po's
"read abort" is both a passive matcher and an active abort fallback, so an
unsynchronized read abort right after a write can race the request delivery
itself. This alone does not yet make shouldAbortToolCreatePr pass — it
surfaces a deeper, separate issue in the mcp_openapi-to-mcp_http relay,
under active investigation.
… opened

McpOpenapiClientFactory's doMcpOpenapiEnd/doMcpOpenapiAbort silently dropped
the signal when the reply direction hadn't sent a BEGIN yet (e.g. the
upstream aborts before mcp_http0 ever produces a response), leaving the real
MCP client hanging with no reply frame at all. Open the reply first,
mirroring the existing doHttpBegin-before-abort pattern already used
elsewhere in this codebase, so END/ABORT are always deliverable.

Also guard doMcpOpenapiBegin so it only opens the reply once.
…-response

McpHttpProxy.onHttpAbort tore down the response (cleanupResponse) and
signaled the mcp reply direction (doMcpAbort) but never released encodeSlot,
so any response mid-construction when the upstream aborts (error-relay's
TOOL_ERROR_PREFIX, or a partially streamed success envelope) leaked that
buffer for the lifetime of the worker. The parallel onHttpEnd path already
drains and releases it correctly via pumpResponse's terminal-status
handling; onHttpAbort short-circuits past that, so it must release the slot
directly.

This is the confirmed root cause of the intermittent
McpOpenapiClientIT#shouldAbortToolCreatePr failures ("Some resources not
released: 1 buffers" / "close timeout") — traced via direct acquire/release
instrumentation showing the mcp-side reply encodeSlot acquired during
error-relay setup with no matching release once the upstream aborted before
ever completing a response. Verified 27/27 across three batches with the
fix applied, versus frequent failures before it.

Adds a dedicated regression scenario (create.pr.error.aborted, in both
binding-mcp-http.spec and binding-mcp-openapi.spec) that sends a real
non-2xx status begin before aborting, deterministically triggering
error-relay mode before the abort — confirmed failing 3/3 without the fix
and passing 5/5 with it.

Also fixes an unrelated oversight from an earlier commit in this branch:
mcp/create.pr.aborted/server.rpt (the peer self-consistency script for
McpServerIT, distinct from the real IT's http/create.pr.aborted/server.rpt)
never got the "write notify REQUEST_RECEIVED" its paired client.rpt was
updated to await, causing a deterministic 5s timeout in that peer test.

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

Moves bodyTemplate, with.query, and the resources/read request path onto
the same streaming JsonPipeline architecture already used for with.body,
replacing the fully-buffered onMcpRequest fallback. McpToolsCallProxy and
McpResourcesReadProxy each fully dispatch their own request shape from
onMcpBegin/pumpRequest now, keeping the two kinds' streaming logic
independent per their existing separation.

New McpHttpQuery and McpHttpDiscard terminal JsonSink transforms capture
a projected query object and discard a validate-only body respectively.
Removes projectBuffer, runInto, projectInto, templateInto, validate,
newValidator/newProjector/newTemplate, and queryStringFromBytes, which
have no callers left once every request shape streams directly.

Also fixes a decode-slot leak: McpProxy.onMcpData kept accumulating body
bytes into a decode slot after a request was already dispatched (e.g. by
McpResourcesReadProxy's now-eager onMcpBegin), leaking the slot forever
since onMcpRequest never fires again to release it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjuRiuQF9f7Z4rSrsFXbWB
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.

refactor(binding-mcp-http): split the general-purpose proxy stream into per-request-kind stream classes

2 participants