feat(examples): mcp.proxy demonstrates url-mode elicitation aggregation - #1793
Closed
jfallows wants to merge 20 commits into
Closed
feat(examples): mcp.proxy demonstrates url-mode elicitation aggregation#1793jfallows wants to merge 20 commits into
jfallows wants to merge 20 commits into
Conversation
jfallows
force-pushed
the
claude/gallant-cray-TVVuP
branch
from
May 23, 2026 19:41
9e7199a to
b2ddbc9
Compare
jfallows
marked this pull request as draft
May 23, 2026 19:54
This was referenced May 23, 2026
jfallows
force-pushed
the
claude/gallant-cray-TVVuP
branch
4 times, most recently
from
May 28, 2026 05:22
1efdc93 to
45cbeb6
Compare
This was referenced Jun 2, 2026
Merged
jfallows
force-pushed
the
claude/gallant-cray-TVVuP
branch
from
June 3, 2026 02:13
760a7c7 to
babb253
Compare
Aggregate the `everything` reference server and a minimal `urlelicit` upstream behind one Streamable HTTP endpoint, demonstrating MCP protocol 2025-11-25 negotiation and SEP-1036 url-mode elicitation pass-through. - `url-elicit/`: a minimal MCP server on the published @modelcontextprotocol/sdk that emits a `mode:"url"` `elicitation/create` on its `authorize` tool call and signals completion via `notifications/elicitation/complete` — no OAuth, runs as a plain Streamable HTTP upstream. - `zilla.yaml`: north http→mcp(server)→mcp(proxy) with a shared in-memory cache, fanning out to `everything` and `urlelicit` mcp(client) routes by toolkit. - when the client advertises `elicitation.url`, Zilla advertises it upstream and relays the url-mode elicitation round-trip in both directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jfallows
force-pushed
the
claude/gallant-cray-TVVuP
branch
from
June 5, 2026 16:06
babb253 to
92ee8df
Compare
Aggregate the `everything` reference server and a minimal `urlelicit` upstream behind one Streamable HTTP endpoint, demonstrating MCP protocol 2025-11-25 negotiation and SEP-1036 url-mode elicitation pass-through. - `url-elicit/`: a minimal MCP server on the published @modelcontextprotocol/sdk that emits a `mode:"url"` `elicitation/create` on its `authorize` tool call and signals completion via `notifications/elicitation/complete` — no OAuth, runs as a plain Streamable HTTP upstream. - `zilla.yaml`: north http→mcp(server)→mcp(proxy) with a shared in-memory cache, fanning out to `everything` and `urlelicit` mcp(client) routes by toolkit. - when the client advertises `elicitation.url`, Zilla advertises it upstream and relays the url-mode elicitation round-trip in both directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onDecodeNotifyCancelled looked up the request stream by requestId and called doAppCancel without a null check. A notifications/cancelled for an unknown or already-completed requestId returned null, dereferenced it, and threw NPE on the worker thread -> AgentTerminationException, terminating the EngineWorker (container auto-restarted). Guard the lookup so an unknown requestId is still acknowledged with 202 Accepted and never terminates the worker. Adds the notifications.cancelled.unknown scenario (NetworkIT peer-to-peer + McpServerIT against the engine, which reproduced the AgentTermination before the fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (WIP)
Defines the conformant url-mode elicitation wire contract so it can be
implemented test-first. Faithful relay: the upstream's original JSON-RPC
request id and elicitationId both flow through unchanged (Zilla invents
neither).
- mcp.idl: McpElicitAction{ACCEPT,DECLINE,CANCEL}, McpElicitResponseFlushEx
{requestId, action}, +message/+requestId on McpElicitCreateChallengeEx
- McpFunctions: builders/matchers for the above
- tools.call.elicit.completed (net+app, client+server): rewritten to the
conformant envelope -- elicitation/create as a request WITH id + message,
client {id,result:{action}} response, notifications/elicitation/complete,
then the tool result. net+app peer-to-peer pass.
Discriminator for passthrough vs the guard/OAuth broker: an upstream-supplied
request id means passthrough; a Zilla-initiated (guard) elicitation has none
and stays broker. So passthrough is the default and broker is guard-only.
McpServerIT.shouldCallToolElicitCompleted is @ignore'd pending the north
implementation (emit create-with-id, decode the client response into an
elicitResponse FLUSH, emit notifications/elicitation/complete).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…claude/gallant-cray-TVVuP
…n-stream wiring (WIP)
Request-stream passthrough (north+south+proxy): elicitation/create relayed with upstream JSON-RPC id, result {action} decoded and relayed, conformant notifications/elicitation/complete. Session/GET-stream wiring (lifecycle path) implemented across north/south/proxy. McpFunctions coverage for elicitResponse + elicitCreate requestId/message. NPE crash-guard in McpRequestStream.doAppEnd (null requestId). Conformant tools.call.elicit.completed.proxied scripts; new lifecycle.elicit.completed scenario + ITs (McpServerIT/McpProxyIT/NetworkIT/ApplicationIT). tools.call.elicit.completed.context @ignore'd (broker callback, issue #1810).
All binding-mcp (229, 1 skip) + spec (211) ITs green; coverage met. Example e2e NOT yet working: blocked on north StreamableHTTP SSE response framing (see NORTH_SSE_FRAMING_DESIGN.md) + proxy session GET-listener establishment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n note (WIP) test.sh url-mode elicitation e2e assertion; url-elicit package-lock. NORTH_SSE_FRAMING_DESIGN.md captures the remaining north StreamableHTTP SSE-response-framing change (scratch handoff note; remove before the develop PR). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
North answered notifications/initialized with 200 + application/json. The MCP spec requires a JSON-RPC notification to receive 202 Accepted with no body, and the StreamableHTTP SDK client opens its standalone GET SSE stream (the channel for server-initiated requests such as elicitation/create) only after a 202 to notifications/initialized. With 200 the client never opens the GET, so server-initiated elicitation could not be relayed through the gateway. Emit 202 with no body. Tighten the net client.rpt scripts that send notifications/initialized to assert the 202 response; previously they did a bare `read closed`, which hid the wrong status from McpServerIT while NetworkIT's server.rpt already wrote 202. Also remove NORTH_SSE_FRAMING_DESIGN.md: its premise (that the initialize response content-type is the client's capability signal) was a misdiagnosis superseded by this 202 fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(server+client)
Two defects prevented server-initiated url-mode elicitation from flowing
end-to-end between the mcp server and mcp client bindings:
1. McpEventStream.onNetClientEnd tore down the standalone GET's reply SSE
stream (and cleared session.sse) when the client's bodyless GET request
completed its initial side — which happens immediately, since a GET has no
body. The standing SSE channel for server-initiated requests was therefore
destroyed before any elicitation/create could be emitted. Keep the reply
open on the request-side END; client disconnects are still handled by
onNetAbort/onNetReset.
2. HttpElicitResponse POSTed the client's {action} response to the upstream
without a content-length and without honoring flow control, so the body was
never delivered (the upstream parsed an empty body and elicitInput never
resolved, hanging the tool call). Declare content-length and send the body
once the upstream grants sufficient window, as a fire-and-forget request.
Lock the corrected standing-GET semantics in the network IT scripts: the
lifecycle.events.{open,evict,keepalive} and lifecycle.notify.*.list.changed
scenarios now model the GET staying open across the request-side END, and
lifecycle.elicit.completed sends the GET request-side END before the
server-initiated events to regression-test fix (1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
newSessionId reject-samples a random session id until its hashCode routes back to the worker that owns the session state (isLocalIndex). The default attempt budget was workers*2, so each generation returned null with probability ((N-1)/N)^(2N) ~= e^-2 ~= 13.5%; a null aborts session creation and hangs the lifecycle. Raise the default to workers*64 (~e^-64) and extract the three identical sampling loops in McpClientFactory, McpServerFactory and McpProxyLifecycleFactory into a shared, unit-testable McpSessionId helper. McpSessionIdTest emulates EngineWorker.isLocalIndex for an uncapped binding (mask == all workers): it demonstrates exhaustion at the legacy workers*2 cap and proves no null at workers*64 across worker counts 1..64. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For an HTTP/1.1 server exchange whose request is already complete but whose response is still streaming (e.g. Server-Sent Events), a graceful client disconnect arrives as a network END. HttpExchange.onNetworkEnd only aborted the request and left the open response untouched, so the application's response stream was never reset and was only reaped by inactivity. This is inconsistent with HTTP/2, which resets the reply on END via cleanup. When the response is OPEN on network END, reset it and abort the network reply, mirroring the existing onDecodeBodyInvalid path. The responseState == OPEN guard preserves the valid HTTP half-close case (client closes its request side then reads the full response), which completes via the existing replyCloseOnFlush path. Add server IT client.close.during.response: a streaming response is reset (write aborted) when the client closes the connection mid-response. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ends The south mcp client keepalive pings the remote mcp server every inactivity.timeout/2 to keep the upstream session alive. When the downstream client disconnects, north relays a SUSPENDED challenge, but south continued pinging the remote server until the inactivity timeout reaped the session. Cancel the keepalive on SUSPENDED (downstream gone) and re-arm it on RESUME (downstream reconnected), so Zilla stops pinging the remote server once the remote client has disconnected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running the SDK client via `docker compose run urlelicit` made the one-off client container claim the `urlelicit` network alias alongside the server, so docker DNS round-robined `urlelicit:3003` onto the (server-less) client container and Zilla's upstream connection failed intermittently. Add a distinct `urlelicit-client` service (deploy.replicas: 0 so `up` skips it, runnable on demand) and drive the client through it from test.sh, leaving the `urlelicit` alias resolving only to the real server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t close Mirror the rfc7230 client.close.during.response scenario for HTTP/2: a server exchange whose request is complete but whose response is still streaming is reset (write aborted) when the client closes the connection mid-response. HTTP/2 already handles this via Http2Server cleanup on network END; this locks in parity with the HTTP/1.1 fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a remote client reconnects the standing GET SSE stream with a
Last-Event-Id, the mcp server binding must replay/resume from that event and
propagate it to the remote mcp server. The session-level resume path built the
resume challenge with no id (McpEventStream.onNetBegin -> session.doAppResume
-> resume(b -> {})), and the Last-Event-Id parser only captured a suffix when
the id contained a prefix delimiter, so a plain id like "event-99" was dropped.
Thread the resumed event id through the session resume (mirroring the request
path) and pass the full Last-Event-Id when there is no prefix. End to end this
makes a downstream GET carrying Last-Event-Id produce an upstream GET to the
remote server carrying the same Last-Event-Id.
Wire the lifecycle.suspend.events and lifecycle.events.resume scenarios into
McpServerIT, and complete the lifecycle.events.resume peer scripts to model the
full resume(id) -> resumable -> suspend round-trip so the scenario passes in
McpServerIT, McpClientIT, NetworkIT and ApplicationIT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…proxy Make the aggregating mcp proxy hold a standing event stream to each upstream so server-initiated elicitation/create requests reach the downstream client, proven end-to-end against the mcp.proxy example. - onServerChallenge: on a fresh resume (no aggregate event id) establish a south lifecycle per route before resuming existing clients, so the upstream GET opens when the downstream opens its event stream. In the cached case the clients map was empty at resume time, so no standing GET opened and elicitation/create had nowhere to flow. - onClientBegin: issue the standing-GET resume before forwarding queued requests, so the upstream receives the GET before the tools/call POST. Otherwise elicitInput fires upstream with no event stream and the create request is lost on a cold start. - Stop aggregating the elicitComplete id: it carries the elicitationId the client correlates create and complete by, and must pass through verbatim like the elicitCreate id. Covered by McpProxyCacheIT#shouldCompleteLifecycleElicitWhenCached, which models the deferred-establishment (clients=0) case with a seeded aggregating cache and asserts the full elicit round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The elicit correlation id ties together elicit-create, the client's response, and (for broker mode) the callback — the callback is not part of a request, so requestId read wrong. Rename the field on the elicit extension types and the elicit-handling code to correlationId. The generic JSON-RPC request machinery (session.requests, ordinary request ids) is unchanged. Behavior-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an optional authCallback field to McpLifecycleBeginEx. The north derives its OAuth callback URL (resolveRedirectURI, as today) and sets it on the app lifecycle BEGIN; the proxy propagates it onto each south client's lifecycle BEGIN; the south stores it on the session. Unused for now — it lets the guard build the authorization URL with the correct redirect_uri (replacing the north's URL surgery) in a follow-on change. Additive and behavior-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation Make the mcp server react to elicitation/create identically in broker (guard/OAuth) and passthrough modes, shrinking the broker to its irreducible core: host the OAuth callback and layer routing onto the OAuth state. - discriminate broker at the north by the create url's redirect_uri being anchored at the server's own callback (replaces the requestId/context presence heuristics); reduce manipulateElicitUrl to state-layering only - south mints the broker elicitationId and correlationId, passes the plumbed authCallback to guard.preauthorize, and branches the elicit response by mode: passthrough POSTs upstream; broker accept awaits the callback, decline/cancel cancels the guard flow and fails the gated request - north emits one notifications/elicitation/complete and relays the client answer down uniformly; emit notification-style create when no correlationId is present - proxy prefixes the correlationId per toolkit and routes both the response and callback back by that prefix; drop the elicitClients map and the context breadcrumb - drop the status field from elicitComplete and the McpElicitStatus enum Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
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.
Description
The
examples/mcp.proxyexample, rebased ontodevelopnow that all thebinding-mcp / binding-http runtime work it relied on has merged independently
(#1812, #1814, #1816, #1818, #1820). Only the example remains here.
Aggregates the
everythingreference server and a minimalurlelicitupstreambehind one Streamable HTTP endpoint, demonstrating:
routes[].when.toolkitfans/mcpintomultiple upstream MCP servers, with a shared in-memory cache for
tools/prompts/resourceslistings.2025-11-25and echoes thenegotiated version (feat(binding-mcp): offer 2025-11-25 and negotiate elicitation url capability #1820).
everythingserver.elicitation.url, Zilla advertises it upstream and relays themode:"url"elicitation/createandnotifications/elicitation/completeend-to-end. Thebundled
url-elicit/server (published@modelcontextprotocol/sdk, no OAuth)exercises this on its
authorizetool.Testing
Verified end-to-end against a
develop-built Zilla image (docker compose up):initializenegotiates2025-11-25,everything+urlelicittoolkitsaggregate in
tools/list, no worker faults. Smoke test:./.github/test.sh.Interactive url-mode round-trip via MCP Inspector (documented in the README).
Notes
not in this OSS example — it will be demonstrated in a zilla-plus example
alongside the per-toolkit
oauthguard (binding-mcp: per-toolkit elicitation formcpproxy #1810).time(mcp-proxy bridge) route and a headless SDK-client driver were trialedand dropped: both surfaced independent Zilla interop edges (tools not
aggregated from the mcp-proxy bridge; SDK
StreamableHTTPClientTransport→Zilla
-32700) to be investigated separately.🤖 Generated with Claude Code