Skip to content

feat(examples): mcp.proxy demonstrates url-mode elicitation aggregation - #1793

Closed
jfallows wants to merge 20 commits into
developfrom
claude/gallant-cray-TVVuP
Closed

feat(examples): mcp.proxy demonstrates url-mode elicitation aggregation#1793
jfallows wants to merge 20 commits into
developfrom
claude/gallant-cray-TVVuP

Conversation

@jfallows

@jfallows jfallows commented May 23, 2026

Copy link
Copy Markdown
Contributor

Description

The examples/mcp.proxy example, rebased onto develop now that all the
binding-mcp / binding-http runtime work it relied on has merged independently
(#1812, #1814, #1816, #1818, #1820). Only the example remains here.

Aggregates the everything reference server and a minimal urlelicit upstream
behind one Streamable HTTP endpoint, demonstrating:

  • Multi-toolkit aggregationroutes[].when.toolkit fans /mcp into
    multiple upstream MCP servers, with a shared in-memory cache for
    tools/prompts/resources listings.
  • Protocol version negotiation — Zilla offers MCP 2025-11-25 and echoes the
    negotiated version (feat(binding-mcp): offer 2025-11-25 and negotiate elicitation url capability #1820).
  • Form elicitation pass-through — via the everything server.
  • URL-mode elicitation pass-through (SEP-1036) — when the client advertises
    elicitation.url, Zilla advertises it upstream and relays the mode:"url"
    elicitation/create and notifications/elicitation/complete end-to-end. The
    bundled url-elicit/ server (published @modelcontextprotocol/sdk, no OAuth)
    exercises this on its authorize tool.

Testing

Verified end-to-end against a develop-built Zilla image (docker compose up):
initialize negotiates 2025-11-25, everything + urlelicit toolkits
aggregate in tools/list, no worker faults. Smoke test: ./.github/test.sh.
Interactive url-mode round-trip via MCP Inspector (documented in the README).

Notes

  • github (PAT pass-through, bootstrap credential, 401-challenge) is intentionally
    not in this OSS example — it will be demonstrated in a zilla-plus example
    alongside the per-toolkit oauth guard (binding-mcp: per-toolkit elicitation for mcp proxy #1810).
  • A time (mcp-proxy bridge) route and a headless SDK-client driver were trialed
    and 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

@jfallows
jfallows force-pushed the claude/gallant-cray-TVVuP branch from 9e7199a to b2ddbc9 Compare May 23, 2026 19:41
@jfallows
jfallows marked this pull request as draft May 23, 2026 19:54
@jfallows
jfallows force-pushed the claude/gallant-cray-TVVuP branch 4 times, most recently from 1efdc93 to 45cbeb6 Compare May 28, 2026 05:22
@jfallows jfallows changed the title Add mcp.proxy example with JWT auth and multi-toolkit aggregation feat(binding-mcp): multi-toolkit MCP proxy with shared cache, multi-worker session affinity, and mcp.proxy example Jun 2, 2026
@jfallows
jfallows force-pushed the claude/gallant-cray-TVVuP branch from 760a7c7 to babb253 Compare June 3, 2026 02:13
@jfallows jfallows changed the title feat(binding-mcp): multi-toolkit MCP proxy with shared cache, multi-worker session affinity, and mcp.proxy example feat(examples): mcp.proxy demonstrates url-mode elicitation aggregation Jun 3, 2026
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
jfallows force-pushed the claude/gallant-cray-TVVuP branch from babb253 to 92ee8df Compare June 5, 2026 16:06
jfallows and others added 6 commits June 5, 2026 20:05
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>
…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>
jfallows and others added 13 commits June 6, 2026 08:36
…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>

jfallows commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #1852, which landed the mcp.proxy url-mode elicitation example on develop as a clean 8-file change. The runtime/spec work this branch carried merged independently (#1812, #1814, #1816, #1818, #1820, #1850, #1851), so nothing unique remains here.


Generated by Claude Code

@jfallows jfallows closed this Jun 8, 2026
@jfallows
jfallows deleted the claude/gallant-cray-TVVuP branch July 1, 2026 23:04
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.

1 participant