Say what an upstream credential refusal actually was #347

Merged
coilysiren merged 1 commit from mcp-auth into main 2026-08-30 05:47:39 +00:00
Owner

Refs #340, which opens with two questions before any code. Answering both, recommending against the browser flow, and shipping the part that is useful either way.

The questions

1. Does the browser flow belong in umbra? Recommend no.

umbra is a policy-free engine that holds no state. It has never opened a browser, never persisted a credential, and its whole value proposition is that a reviewer can read one guardfile and know the boundary. An interactive OAuth flow adds a listener, a redirect, and a token store, and every one of those is a thing a reviewer now has to trust separately.

The alternative already works: something else mints the token, umbra reads it through a value chain. That is what auth bearer { value ssm "..." } does today, and it keeps the acquisition problem outside a component whose job is refusal.

2. Where would a refresh token live? It does not arise under the above, which is the point of answering the first one first. A refresh token rotating on every use fits SSM poorly, and #340 already noticed that. The best fix for a bad answer is a question you do not have to ask.

What this PR ships

The gap was silent. A 401 surfaced as the SDK's bare word Unauthorized, which does not say whether a credential was sent at all, and does not say the server asked for OAuth. The operator inferred both.

The header transport already sees the response, so it now records a 401/403 and its WWW-Authenticate challenge:

mcpclient: connect api (https://host/mcp): the upstream refused the credential
(HTTP 401); this `mcp http` block declares no `auth`. It advertises OAuth, which
umbra does not acquire: supply an already-minted token through
`auth bearer { value ... }`

Three distinctions it now makes:

  • no auth declared, versus a declared value that resolved and was rejected - different problems with different fixes
  • the server advertised OAuth, versus it just said no
  • a 500 gets none of this. Sending an operator after a token that was never the problem is worse than saying nothing, and there is a test for that.

The transport wrapper is now always installed rather than only when a header is declared, because the no-credential case is exactly the one worth reporting and it previously had no observer.

Scope

This does not close #340. It removes the silence, records the recommendation, and leaves the decision with you. If you want the browser flow in umbra after all, the disagreement is worth having explicitly rather than by my building it.

go test ./..., golangci-lint run ./..., pre-commit run --all-files pass.

Refs #340, which opens with **two questions before any code**. Answering both, recommending against the browser flow, and shipping the part that is useful either way. ## The questions **1. Does the browser flow belong in umbra?** Recommend **no**. umbra is a policy-free engine that holds no state. It has never opened a browser, never persisted a credential, and its whole value proposition is that a reviewer can read one guardfile and know the boundary. An interactive OAuth flow adds a listener, a redirect, and a token store, and every one of those is a thing a reviewer now has to trust separately. The alternative already works: something else mints the token, umbra reads it through a value chain. That is what `auth bearer { value ssm "..." }` does today, and it keeps the acquisition problem outside a component whose job is refusal. **2. Where would a refresh token live?** It does not arise under the above, which is the point of answering the first one first. A refresh token rotating on every use fits SSM poorly, and #340 already noticed that. The best fix for a bad answer is a question you do not have to ask. ## What this PR ships The gap was **silent**. A 401 surfaced as the SDK's bare word `Unauthorized`, which does not say whether a credential was sent at all, and does not say the server asked for OAuth. The operator inferred both. The header transport already sees the response, so it now records a 401/403 and its `WWW-Authenticate` challenge: ``` mcpclient: connect api (https://host/mcp): the upstream refused the credential (HTTP 401); this `mcp http` block declares no `auth`. It advertises OAuth, which umbra does not acquire: supply an already-minted token through `auth bearer { value ... }` ``` Three distinctions it now makes: - no `auth` declared, versus a declared value that **resolved and was rejected** - different problems with different fixes - the server advertised OAuth, versus it just said no - **a 500 gets none of this.** Sending an operator after a token that was never the problem is worse than saying nothing, and there is a test for that. The transport wrapper is now always installed rather than only when a header is declared, because the no-credential case is exactly the one worth reporting and it previously had no observer. ## Scope This does not close #340. It removes the silence, records the recommendation, and leaves the decision with you. If you want the browser flow in umbra after all, the disagreement is worth having explicitly rather than by my building it. `go test ./...`, `golangci-lint run ./...`, `pre-commit run --all-files` pass.
feat(mcpclient): say what an upstream credential refusal actually was
All checks were successful
ci / secrets (pull_request) Successful in 10s
ci / lint (pull_request) Successful in 37s
ci / test (pull_request) Successful in 51s
b0159e284a
refs #340

#340 asks two questions before any code: does the browser flow belong in
umbra, and where would a refresh token live. Recommending no on the first,
so the second does not arise, and shipping the part that is useful either
way.

Today a 401 surfaces as the SDK's bare word "Unauthorized". That does not
say whether a credential was sent at all, and it does not say the server
asked for OAuth, so the operator infers both.

The header transport already sees the response, so it now records a 401 or
403 and its WWW-Authenticate challenge. A connect failure then reports
which case it was: no `auth` declared, or a declared value that resolved
and was rejected. When the challenge advertises OAuth it names the gap and
the way round it, an already-minted token through `auth bearer`.

A 500 gets none of this. Sending an operator after a token that was never
the problem is worse than saying nothing.

The transport wrapper is now always installed rather than only when a
header is declared, because the no-credential case is exactly the one worth
reporting and it had no observer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
coilysiren deleted branch mcp-auth 2026-08-30 05:47:40 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra!347
No description provided.