Skip to content

binding-mcp: propagate upstream auth challenges to inbound client #1795

Description

@jfallows

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 opaquerealm 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)

  1. Upstream emits HTTP 403 + WWW-Authenticate: Bearer realm="github", scope="repo".
  2. http(client) emits BEGIN on its app reply (existing behavior).
  3. 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.
  4. mcp(proxy) forwards the RESET extension transparently (new extension-bearing overload).
  5. 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).
  6. http(server) encodes the HTTP response to the inbound client.

Why RESET-with-extension, not CHALLENGE

  1. 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).
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions