Skip to content

Remove MCP direct identity-provider mode - #1334

Closed
jiaenren wants to merge 2 commits into
jiaenr/mcp-smoke-checks-proxy-modefrom
jiaenr/mcp-remove-direct-mode
Closed

Remove MCP direct identity-provider mode#1334
jiaenren wants to merge 2 commits into
jiaenr/mcp-smoke-checks-proxy-modefrom
jiaenr/mcp-remove-direct-mode

Conversation

@jiaenren

@jiaenren jiaenren commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1333. The last structural PR in the MCP authentication simplification stack.

Issue #None

+251 / -1,285.

Why

MCP shipped two ways to authenticate: the Gateway validating a bearer and MCP trusting injected headers, or FastMCP verifying the caller itself. Only one can be the answer, and the published docs already recommended the second. Every route, matcher, config field and code path was conditional on which one was active.

Change

request_context.py 355 → 194 lines. The ASGI middleware, per-request state, header parsing, and the user_name field no consumer read are gone. get_request_credentials() collapses to FastMCP's access token — so caller identity can no longer come from a header a caller could set. That is now structural rather than validated.

auth_enabled goes with it. Authentication isn't a mode, so its configuration is required rather than conditionally required: a deployment that cannot reach an identity provider fails at load instead of starting unauthenticated.

The Gateway stops synthesising what FastMCP serves — the protected-resource route forwards unconditionally, and the local_reply_config that rewrote the /mcp 401 is gone because FastMCP emits it. authorizationServers, scopes, and oidcProxy.enabled (which could only ever be true) are removed, along with the direct-mode CI fixture and the validator half that tested it.

Tests authenticate the way the service does: protocol_harness gains AnyTokenVerifier and a service_config helper, so suites exercising the tool pipeline get a real auth provider instead of trusted headers.

Two behaviour changes, recorded not hidden

1. Unauthenticated requests are rejected after the body is buffered, not before. The deleted middleware enforced fail-before-body; FastMCP's auth runs inside the route, so RequestBodyLimitMiddleware — which is outermost — buffers the bounded body first. The request is still rejected and the body still bounded; only the order changed. A test asserts this explicitly rather than leaving it undiscovered.

2. After a cancelled request, a follow-up on the same client is FastMCP's to decide. I verified against the pre-change tree, with an auth provider installed, that an authenticated deployment already behaved this way — so this PR does not introduce it. It is pre-existing in OIDC-proxy mode and worth its own investigation; a development deployment has it today.

Verification

  • bazel test //src/service/mcp/... //test/smoke/... — 76/76 pass
  • render-tests.sh, validate-mcp-chart.sh — pass
  • User guide, deployment guide, appendix, service README and chart README no longer document a mode that does not exist

🤖 Generated with Claude Code

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

@jiaenren
jiaenren requested a review from a team as a code owner August 27, 2026 00:40
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: b54eb999-2869-47f8-961a-90a5086fbb7b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

jiaenren added a commit that referenced this pull request Aug 27, 2026
#1334 removed oidcProxy.enabled from values.yaml but left the template
reading it, so it fell through to its default of false. Every OIDC
variable sits behind that gate, which means a values file written to the
contract this design documents renders with the whole auth block
missing and the container fails at start on absent configuration. The
render fixture only passed because it still carried the flag.

The service has no auth_enabled field any more -- test_auth asserts
that -- so nothing consumed OSMO_MCP_AUTH_ENABLED either. Authentication
is not a mode, and the chart now says so: the gate, the environment
variable and the flag in the fixture are gone, and the required messages
name MCP rather than a proxy that could be switched off.

Guarded by a render assertion that the auth block renders from a fixture
carrying no enable flag, and that no enable switch is emitted.
jiaenren and others added 2 commits August 28, 2026 12:20
Three reductions to what a deployer must state, none of which changes
what the deployment does.

**Values the chart already knew.** accessTokenAudience had to equal
resourceUrl, scope had to equal resourceUrl plus the scope name, and the
issuer had to be resourceUrl minus /mcp -- and the chart refused to
render unless each matched what it had already computed. A deployer was
asked for an answer the chart held, then rejected for giving a different
one. They are derived now, so the 30-line cross-checking block in
MCPAuthConfig, the matching Helm fail blocks, the three environment
variables carrying the restated values, and the three CI cases that
existed only to prove the cross-checks fired all go.

This also drops trustedHttpsRedirectOrigins, which no deployment sets and
which cost a CSV environment variable, two properties, a validator branch
and a Helm regex loop. Native clients use loopback redirects, still
allowed.

**A URL discovery publishes.** accessTokenJwksUrl is jwks_uri in every
OIDC discovery document, which FastMCP already fetches at startup. OSMO
hand-built a JWTVerifier only to pin it, and carried a process-lifetime
httpx client to serve it. OIDCProxy builds the verifier now, and OSMO
overrides only what discovery cannot supply: an Entra resource
application configured for v1 access tokens issues them from
https://sts.windows.net/<tenant>/ even when its discovery document
advertises the v2.0 issuer, so accessTokenIssuer stays configured.

The audience is applied inside that override rather than through
OIDCProxy's own audience argument, because that argument is forwarded to
the provider's authorize and token endpoints (oidc_proxy.py:432-434),
which Entra does not accept. An existing test asserting that no extra
token parameters are sent caught this.

**Redis that every sibling already shares.** MCP carried its own host,
port and TLS settings. serviceName and port fell back to services.redis
but tlsEnabled did not, so a deployment whose Redis requires TLS could
satisfy every other service and still CrashLoop MCP. All three come from
services.redis now; only dbNumber stays, to isolate proxy state from
OSMO's other Redis users, and its default moves off 0 for that reason.

The chart also refused more than one MCP replica, on the premise that
FastMCP serializes token refresh within a process. FastMCP says the
opposite -- "All state is stored in the configured client_storage
backend (Redis, disk, etc.) enabling horizontal scaling across multiple
instances" (oauth_proxy/proxy.py:212-213), with :1903-1912 handling the
distributed refresh race explicitly. The rule also contradicted itself,
since sharing state through Redis only matters across replicas it
forbade. The negative test asserting the ban is replaced by a positive
one that two replicas render, negative-tested by reintroducing a ban.

Required OIDC proxy values: 10 -> 6.

Note for anyone using --config rather than environment variables: a stale
file carrying issuer_url, auth_scope, oidc_access_token_audience or
oidc_access_token_jwks_url now fails at startup with an unrecognized-key
error rather than being ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MCP shipped two ways to authenticate a caller: the Gateway validating a
bearer and MCP trusting injected headers, or FastMCP verifying the caller
itself. Only one can be the answer, and the published documentation
already recommended the second. This removes the first.

request_context.py loses the header trust boundary entirely: the ASGI
middleware, the per-request state, the header parsing, and the user_name
field that no consumer read. get_request_credentials collapses to the
FastMCP access token, which is now the only source of caller identity, so
identity can no longer come from a header a caller could set. The module
goes from 355 lines to 194.

auth_enabled disappears with it. Authentication is not a mode any more, so
its configuration is required rather than conditionally required, and a
deployment that cannot reach an identity provider fails at load instead of
starting up unauthenticated.

The Gateway stops synthesising what FastMCP serves: the protected-resource
route forwards unconditionally, and the local_reply_config that rewrote
the /mcp 401 challenge is gone because FastMCP emits it. services.mcp
loses authorizationServers and scopes, and oidcProxy loses its enabled
toggle, which could only ever be true.

Tests authenticate the way the service does. protocol_harness gains a
static token verifier and a service_config helper, so suites that
exercise the tool pipeline get a real auth provider instead of trusted
headers.

The smoke checks come with it, because deploying proxy mode for the first
time showed the suite failing before it reached any assertion: it
authenticates with the OSMO-issued OETF token, which FastMCP rejects.
The unauthenticated surface a client uses to bootstrap OAuth needs no
token and is now its own test -- the 401 challenge and its
resource_metadata pointer, the RFC 9728 document, that FastMCP advertises
its authorization endpoint under /mcp, and that the health endpoints the
/mcp prefix would otherwise publish return 404. The authenticated catalog
and workflow checks name OSMO_MCP_ACCESS_TOKEN, which runs them when an
identity-provider token is supplied.

Two smoke-check fixes ride along. The expected catalog was missing
osmo_list_tasks, added by #1311, so its exact-equality assertion would
have failed at 25 versus 26 had it ever run, and the two counts in the
service README were stale for the same reason. The suite also carried an
`auth` tag that the kind environment's exclude_tags dropped, so it never
ran anywhere -- which is why the staleness went unnoticed. It is tagged
by what it needs deployed now, with kind excluding `mcp` explicitly.

Two behaviours changed and are recorded rather than hidden. An
unauthenticated request is now rejected after RequestBodyLimitMiddleware
has buffered its bounded body rather than before, because FastMCP's auth
runs inside the route. And whether a follow-up request succeeds after a
cancelled one is FastMCP's to decide; verified against the pre-change tree
that an authenticated deployment already behaved this way, so this change
does not introduce it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiaenren

Copy link
Copy Markdown
Collaborator Author

Superseded by #1350, which is the same branch and the same content with the smoke-check commit from #1333 folded in.

GitHub refused to retarget this PR's base (Cannot change the base branch because the pull request is part of a stack) after the stack was consolidated from 12 PRs to 6, so it had to be reopened rather than retargeted. No content changed.

@jiaenren jiaenren closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant