Summary
Today McpClientFactory.onNetBegin (~line 4081) special-cases only HTTP 405 (events-unsupported fallback); every other non-2xx upstream response is silently dropped at mcp.onDecodeResumable(...) around line 4097. The upstream's :status and any WWW-Authenticate challenge never reach the inbound client. This blocks the examples/mcp.proxy GitHub demo (and any equivalent), where github-mcp-server emits HTTP 403 + WWW-Authenticate: Bearer realm="…", scope="…" and the inbound client should be able to discover the scopes it needs, mint a PAT, and retry.
Proposed change
Introduce a new McpResetEx extension that carries an MCP-level abstraction of the upstream auth challenge, propagates it as a single RESET-with-extension through mcp(proxy) → mcp(server), and re-renders it as an HTTP error response at the mcp(server) → http(server) boundary.
IDL (specs/binding-mcp.spec/src/main/resources/META-INF/zilla/mcp.idl)
union McpResetEx switch (uint8) extends core::stream::Extension
{
case 0: mcp::stream::McpBearerResetEx bearer;
}
struct McpBearerResetEx
{
string16 realm = null;
string16 scope = null;
}
- Union, not flat struct — sets up
Basic, Digest, custom schemes as additive cases without IDL breakage.
- Structured, not opaque —
realm and scope are parsed at the binding-mcp ↔ binding-http boundaries; the MCP wire never sees HTTP syntax. Future non-HTTP MCP transports reuse the same struct.
- Both fields optional per RFC 6750 (empty
Bearer challenge round-trips).
Frame flow (upstream 403 + Bearer challenge)
- Upstream emits
HTTP 403 + WWW-Authenticate: Bearer realm="github", scope="repo".
http(client) emits BEGIN on its app reply (existing behavior).
mcp(client).onNetBegin detects :status ∈ {401, 403}, parses Bearer params from www-authenticate, builds McpResetEx.bearer, emits a single RESET-with-extension on the application initial stream. Resets the net side to release the upstream.
mcp(proxy) forwards the RESET extension transparently (new extension-bearing overload).
mcp(server) decodes bearer, formats RFC-6750 WWW-Authenticate: Bearer realm="…", scope="…", emits BEGIN-with-HttpBeginEx (:status: 401 + www-authenticate) on the net reply — same shape the existing 405-reject path uses (McpServerFactory.java:3497).
http(server) encodes the HTTP response to the inbound client.
Why RESET-with-extension, not CHALLENGE
- The transport-layer event is terminal — the MCP request can no longer be satisfied on this stream. RESET models "this attempt is over"; CHALLENGE models "stay open, I need something from you" (existing uses:
resume, suspended, elicitCreate).
- CHALLENGE forces a teardown coordination problem — both ends would race to close after the challenge is delivered. RESET-with-extension fuses "the why" and "the close" into one atomic frame.
HttpResetEx already establishes the precedent at the HTTP layer.
Why HTTP 401 outbound (not 403)
When the response carries WWW-Authenticate, HTTP 401 is the convention-aligned status ("authenticate and retry"). The 403 distinction (authenticated-but-denied) is information the inbound client can't act on differently. Collapsing 401/403 to 401 outbound keeps the IDL free of HTTP-status modeling.
General failure (any other non-2xx)
Bare RESET (no extension), unchanged from today's behavior. http(server) renders this as a generic HTTP error via its existing fallthrough. No IDL or runtime changes needed for that path.
Holdback invariant
This relies on the existing frame-timing asymmetry: mcp(client) is proactive about emitting END on its net initial (releases upstream eagerly), while mcp(server) holds back END on its app initial precisely so reverse signals (today: McpChallengeEx; with this change: McpResetEx) can flow back on the still-open request stream. No change to that timing.
Scope
- IDL addition (above)
McpFunctions.resetEx() / matchResetEx() builders & matchers (mirroring challengeEx)
McpClientFactory — new branch in onNetBegin for 401/403; new doAppReset(..., Flyweight extension) overload
McpProxyFactory — add RESET propagation (no RESET handling exists today) with extension passthrough
McpServerFactory — handle McpResetEx on app initial, emit :status: 401 + www-authenticate via the existing 405-path shape
- Three spec scenarios under
network/ and application/: client.reject.upstream.bearer/, proxy.propagate.reset.bearer/, server.render.reset.bearer/
- IT methods in
McpClientIT / McpProxyIT / McpServerIT
Out of scope (deferred)
- Basic, Digest, or custom auth-challenge schemes — additive union cases for a follow-up.
- An OAuth-style guard implementing
preauthorize() — lives in zilla-plus/examples/mcp.proxy.oauth (planned).
- Token exchange (JWT → PAT) — zilla-plus territory.
Test-first plan
Per AGENTS.md, scripts first (with the existing k3po reset-ext limitation acknowledged — see commented-out blocks in specs/binding-mcp.spec/.../reject.method.not.allowed/{client,server}.rpt). Where read zilla:reset.ext / reject zilla:reset.ext k3po primitives aren't yet supported, the spec scenarios will assert the abort + the observable HTTP-side bytes; IT-level Java assertions backfill the rest.
Motivation
Required by examples/mcp.proxy (PR #1793) — the GitHub upstream PAT-discovery demo currently silently drops the upstream's auth challenge, so the inbound client sees nothing actionable.
Summary
Today
McpClientFactory.onNetBegin(~line 4081) special-cases onlyHTTP 405(events-unsupported fallback); every other non-2xx upstream response is silently dropped atmcp.onDecodeResumable(...)around line 4097. The upstream's:statusand anyWWW-Authenticatechallenge never reach the inbound client. This blocks theexamples/mcp.proxyGitHub demo (and any equivalent), wheregithub-mcp-serveremitsHTTP 403 + WWW-Authenticate: Bearer realm="…", scope="…"and the inbound client should be able to discover the scopes it needs, mint a PAT, and retry.Proposed change
Introduce a new
McpResetExextension that carries an MCP-level abstraction of the upstream auth challenge, propagates it as a single RESET-with-extension throughmcp(proxy)→mcp(server), and re-renders it as an HTTP error response at themcp(server) → http(server)boundary.IDL (
specs/binding-mcp.spec/src/main/resources/META-INF/zilla/mcp.idl)Basic,Digest, custom schemes as additive cases without IDL breakage.realmandscopeare parsed at the binding-mcp ↔ binding-http boundaries; the MCP wire never sees HTTP syntax. Future non-HTTP MCP transports reuse the same struct.Bearerchallenge round-trips).Frame flow (upstream 403 + Bearer challenge)
HTTP 403 + WWW-Authenticate: Bearer realm="github", scope="repo".http(client)emits BEGIN on its app reply (existing behavior).mcp(client).onNetBegindetects:status∈ {401, 403}, parses Bearer params fromwww-authenticate, buildsMcpResetEx.bearer, emits a single RESET-with-extension on the application initial stream. Resets the net side to release the upstream.mcp(proxy)forwards the RESET extension transparently (new extension-bearing overload).mcp(server)decodesbearer, formats RFC-6750WWW-Authenticate: Bearer realm="…", scope="…", emits BEGIN-with-HttpBeginEx (:status: 401+www-authenticate) on the net reply — same shape the existing 405-reject path uses (McpServerFactory.java:3497).http(server)encodes the HTTP response to the inbound client.Why RESET-with-extension, not CHALLENGE
resume,suspended,elicitCreate).HttpResetExalready establishes the precedent at the HTTP layer.Why HTTP 401 outbound (not 403)
When the response carries
WWW-Authenticate, HTTP 401 is the convention-aligned status ("authenticate and retry"). The 403 distinction (authenticated-but-denied) is information the inbound client can't act on differently. Collapsing 401/403 to 401 outbound keeps the IDL free of HTTP-status modeling.General failure (any other non-2xx)
Bare RESET (no extension), unchanged from today's behavior.
http(server)renders this as a generic HTTP error via its existing fallthrough. No IDL or runtime changes needed for that path.Holdback invariant
This relies on the existing frame-timing asymmetry:
mcp(client)is proactive about emitting END on its net initial (releases upstream eagerly), whilemcp(server)holds back END on its app initial precisely so reverse signals (today:McpChallengeEx; with this change:McpResetEx) can flow back on the still-open request stream. No change to that timing.Scope
McpFunctions.resetEx()/matchResetEx()builders & matchers (mirroringchallengeEx)McpClientFactory— new branch inonNetBeginfor 401/403; newdoAppReset(..., Flyweight extension)overloadMcpProxyFactory— add RESET propagation (no RESET handling exists today) with extension passthroughMcpServerFactory— handleMcpResetExon app initial, emit:status: 401+www-authenticatevia the existing 405-path shapenetwork/andapplication/:client.reject.upstream.bearer/,proxy.propagate.reset.bearer/,server.render.reset.bearer/McpClientIT/McpProxyIT/McpServerITOut of scope (deferred)
preauthorize()— lives inzilla-plus/examples/mcp.proxy.oauth(planned).Test-first plan
Per
AGENTS.md, scripts first (with the existing k3po reset-ext limitation acknowledged — see commented-out blocks inspecs/binding-mcp.spec/.../reject.method.not.allowed/{client,server}.rpt). Whereread zilla:reset.ext/reject zilla:reset.extk3po primitives aren't yet supported, the spec scenarios will assert the abort + the observable HTTP-side bytes; IT-level Java assertions backfill the rest.Motivation
Required by
examples/mcp.proxy(PR #1793) — the GitHub upstream PAT-discovery demo currently silently drops the upstream's auth challenge, so the inbound client sees nothing actionable.