RemoteMCPConfig.auth_type documents three values (src/bernstein/mcp/remote_transport.py:269):
auth_type: str = "bearer" # "none", "bearer", "oauth"
_authenticate (remote_transport.py:1595) implements two of them. "none" admits, "bearer" compares a shared secret in constant time, and everything else reaches:
# Unknown auth type - deny.
return False
So a server configured with auth_type="oauth" — a value the config's own comment presents as supported — refuses every request, including correctly authenticated ones.
That would be a small papercut on its own. What makes it worth an issue is that the rest of the OAuth surface is real and points clients straight at it:
src/bernstein/mcp/oauth.py resolves a configured issuer URL and serves protected-resource metadata.
_request_base_url (remote_transport.py:1495) exists specifically so the metadata document and the WWW-Authenticate challenge cannot disagree about the URL, and its docstring shows real care about header injection through Host / X-Forwarded-Proto.
_unauthorized_response (remote_transport.py:1520) emits an issuer-bearing challenge on every 401, telling the client where to go and get a token.
We therefore advertise a discovery document, emit a challenge naming an authorization server, accept the matching auth_type value in configuration — and then deny the token that comes back. An operator following our own 401 challenge cannot reach a working state.
The security question the fix has to answer, not skip
Implementing "oauth" as "accept any bearer token that parses" would be worse than the current refusal. A token is only meaningful bound to the authorization server that minted it, and to the resource it was minted for:
- Issuer binding. Nothing in
src/bernstein/mcp/ validates an iss today (grep finds the word only in docstrings and in the advertise-side helper). A token minted by a different authorization server the client also talks to must not authenticate here.
- Audience binding. The token must name this resource, or a token issued for an unrelated service authenticates against ours.
- Failure shape. A token that fails either check must produce the same 401-with-challenge as no token at all, and must not leak which check failed.
These are the checks that make the difference between "OAuth is configured" and "OAuth is enforced," and they are the reason this is a size/m rather than a five-line branch.
Acceptance criteria
Not in scope
Stateless serving is already done (#2506): the transport keeps no per-client session, and the legacy protocol-session header is a dated no-op shim. This issue does not touch that.
Effort
M. The branch is small; the issuer and audience binding, and getting the refusal shape uniform, is the work.
RemoteMCPConfig.auth_typedocuments three values (src/bernstein/mcp/remote_transport.py:269):_authenticate(remote_transport.py:1595) implements two of them."none"admits,"bearer"compares a shared secret in constant time, and everything else reaches:So a server configured with
auth_type="oauth"— a value the config's own comment presents as supported — refuses every request, including correctly authenticated ones.That would be a small papercut on its own. What makes it worth an issue is that the rest of the OAuth surface is real and points clients straight at it:
src/bernstein/mcp/oauth.pyresolves a configured issuer URL and serves protected-resource metadata._request_base_url(remote_transport.py:1495) exists specifically so the metadata document and theWWW-Authenticatechallenge cannot disagree about the URL, and its docstring shows real care about header injection throughHost/X-Forwarded-Proto._unauthorized_response(remote_transport.py:1520) emits an issuer-bearing challenge on every 401, telling the client where to go and get a token.We therefore advertise a discovery document, emit a challenge naming an authorization server, accept the matching
auth_typevalue in configuration — and then deny the token that comes back. An operator following our own 401 challenge cannot reach a working state.The security question the fix has to answer, not skip
Implementing
"oauth"as "accept any bearer token that parses" would be worse than the current refusal. A token is only meaningful bound to the authorization server that minted it, and to the resource it was minted for:src/bernstein/mcp/validates anisstoday (grepfinds the word only in docstrings and in the advertise-side helper). A token minted by a different authorization server the client also talks to must not authenticate here.These are the checks that make the difference between "OAuth is configured" and "OAuth is enforced," and they are the reason this is a size/m rather than a five-line branch.
Acceptance criteria
auth_type="oauth"either authenticates correctly or is removed from the config's documented values. A value the code advertises and unconditionally denies is not acceptable in either direction.WWW-Authenticatechallenge, and the accepted issuer are derived from one configured value, so they cannot drift apart._request_base_urlalready establishes this pattern for the first two.docs/states which auth modes the remote transport supports and what each proves.Not in scope
Stateless serving is already done (#2506): the transport keeps no per-client session, and the legacy protocol-session header is a dated no-op shim. This issue does not touch that.
Effort
M. The branch is small; the issuer and audience binding, and getting the refusal shape uniform, is the work.