mcp http upstreams cannot use OAuth authorization_code, only a pre-resolved token #340

Closed
opened 2026-08-30 03:44:33 +00:00 by coilyco-ops · 1 comment
Owner

Deferred out of #336. Filed because the gap is silent from the guardfile's side: an author reaches for auth and gets the header schemes, with nothing saying the browser flow is absent.

What works today

An mcp http upstream authenticates through the ordinary auth block, header-token or bearer, resolving a value chain per call. So any upstream whose credential is already a token in SSM, an env var, or a file works now.

pkg/tokenmint also mints OAuth client_credentials tokens, so a machine-to-machine upstream is covered.

What does not

MCP's own auth story is OAuth 2.1 authorization_code with PKCE and a browser round-trip, plus refresh-token storage. That is a different grant from client_credentials, and umbra has no interactive flow and no token store for one. An upstream that only offers it cannot be reached at all.

This is why mcporter carries a vault and an auth subcommand, and why #336 scoped both out of phase 1 rather than half-building them.

Two questions before any code

  1. Does the browser flow belong in umbra? umbra is a policy-free engine that has never opened a browser or persisted a credential. A token store is state, and umbra holds none today. The alternative is that a wrapper mints and refreshes, and umbra keeps reading a value chain, which preserves the boundary and costs a moving part outside.
  2. Where would a refresh token live? Kai's convention puts opaque values in SSM. A refresh token rotating on every use fits SSM poorly, so this needs an answer rather than a default.

Meanwhile

The absence should be visible rather than discovered. auth on an mcp http block already fails closed on a query-param scheme with a message naming the supported ones, so the smallest useful step is making that message say the browser flow is not supported at all and point here.

Deferred out of #336. Filed because the gap is silent from the guardfile's side: an author reaches for `auth` and gets the header schemes, with nothing saying the browser flow is absent. ## What works today An `mcp http` upstream authenticates through the ordinary `auth` block, `header-token` or `bearer`, resolving a value chain per call. So any upstream whose credential is already a token in SSM, an env var, or a file works now. `pkg/tokenmint` also mints OAuth `client_credentials` tokens, so a machine-to-machine upstream is covered. ## What does not MCP's own auth story is OAuth 2.1 `authorization_code` with PKCE and a browser round-trip, plus refresh-token storage. That is a different grant from `client_credentials`, and umbra has no interactive flow and no token store for one. An upstream that only offers it cannot be reached at all. This is why mcporter carries a `vault` and an `auth` subcommand, and why #336 scoped both out of phase 1 rather than half-building them. ## Two questions before any code 1. **Does the browser flow belong in umbra?** umbra is a policy-free engine that has never opened a browser or persisted a credential. A token store is state, and umbra holds none today. The alternative is that a wrapper mints and refreshes, and umbra keeps reading a value chain, which preserves the boundary and costs a moving part outside. 2. **Where would a refresh token live?** Kai's convention puts opaque values in SSM. A refresh token rotating on every use fits SSM poorly, so this needs an answer rather than a default. ## Meanwhile The absence should be visible rather than discovered. `auth` on an `mcp http` block already fails closed on a `query-param` scheme with a message naming the supported ones, so the smallest useful step is making that message say the browser flow is not supported at all and point here.
Author
Owner

Recommendation on both questions, and the silence removed in #347

This issue said two questions come before any code. Answering them rather than building past them.

1. Does the browser flow belong in umbra? No.

umbra holds no state. It has never opened a browser, never persisted a credential, and its claim is that a reviewer reads one guardfile and knows the boundary. An authorization_code flow adds a local listener, a redirect handler, and a rotating secret at rest, and each is a thing a reviewer then has to trust separately. That is a larger identity change than adding a transport was.

The alternative already works. Something else mints the token, umbra reads it through a value chain, which is what auth bearer { value ssm "..." } does today. Acquisition stays outside a component whose job is refusal.

2. Where would a refresh token live? It does not arise under the answer above, which is the point of taking the questions in order. This issue already noticed that a token rotating on every use fits SSM poorly. The best fix for an awkward answer is not needing to give one.

What #347 ships regardless of that decision

The gap was silent, which was the worse half. A 401 surfaced as the SDK's bare Unauthorized: no indication whether a credential was sent, and none that the server had asked for OAuth.

The header transport already sees the response, so it now records a 401 or 403 and its WWW-Authenticate challenge, and a connect failure says which case it is:

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 that message now makes, each with a test:

  • no auth declared, versus a declared value that resolved and was rejected, which are different problems with different fixes
  • the server advertised OAuth, versus it simply said no
  • a 500 gets none of this, because sending an operator after a token that was never the problem is worse than saying nothing

This issue's own "Meanwhile" asked for exactly that, and it turned out to be most of the practical value.

What is still open

Whether you accept the recommendation. If you want the browser flow in umbra anyway, that disagreement is worth having explicitly rather than settled by my having built it. Leaving this open for that call rather than closing it on my own reasoning.

## Recommendation on both questions, and the silence removed in #347 This issue said two questions come before any code. Answering them rather than building past them. **1. Does the browser flow belong in umbra? No.** umbra holds no state. It has never opened a browser, never persisted a credential, and its claim is that a reviewer reads one guardfile and knows the boundary. An `authorization_code` flow adds a local listener, a redirect handler, and a rotating secret at rest, and each is a thing a reviewer then has to trust separately. That is a larger identity change than adding a transport was. The alternative already works. Something else mints the token, umbra reads it through a value chain, which is what `auth bearer { value ssm "..." }` does today. Acquisition stays outside a component whose job is refusal. **2. Where would a refresh token live?** It does not arise under the answer above, which is the point of taking the questions in order. This issue already noticed that a token rotating on every use fits SSM poorly. The best fix for an awkward answer is not needing to give one. ## What #347 ships regardless of that decision The gap was **silent**, which was the worse half. A 401 surfaced as the SDK's bare `Unauthorized`: no indication whether a credential was sent, and none that the server had asked for OAuth. The header transport already sees the response, so it now records a 401 or 403 and its `WWW-Authenticate` challenge, and a connect failure says which case it is: ``` 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 that message now makes, each with a test: * no `auth` declared, versus a declared value that resolved and was **rejected**, which are different problems with different fixes * the server advertised OAuth, versus it simply said no * a 500 gets none of this, because sending an operator after a token that was never the problem is worse than saying nothing This issue's own "Meanwhile" asked for exactly that, and it turned out to be most of the practical value. ## What is still open Whether you accept the recommendation. If you want the browser flow in umbra anyway, that disagreement is worth having explicitly rather than settled by my having built it. Leaving this open for that call rather than closing it on my own reasoning.
Sign in to join this conversation.
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#340
No description provided.