feat(mcp): anonymous access to named /mcp entries - #989
Conversation
Implements the inbound-leg half of AISIX-Cloud#1143. When an environment projects the new mcp_auth_settings row (canonical /mcp resource URL) and has at least one enabled oidc_provider, the gateway: - serves the RFC 9728 Protected Resource Metadata document on both /.well-known/oauth-protected-resource and its path-insertion /mcp form, derived entirely from configuration (resource URL, enabled providers' issuers, union of their required_scopes); - attaches WWW-Authenticate challenges to /mcp auth failures: bare resource_metadata on missing credentials, error=invalid_token on rejected credentials, and error=insufficient_scope naming only the currently-required scopes on a scope failure (the scope failure is split into a dedicated JwtInsufficientScope variant that renders byte-identically to JwtClaimsRejected, so /v1 is unchanged; bound_claims policy denials deliberately carry no challenge). Without the settings row the surface is dormant: the well-known routes 404 and no header is attached — every existing environment behaves byte-identically to before. A malformed row (path other than /mcp, query/fragment, non-http scheme) keeps the surface dormant with one process-wide warning. The resources file treats mcp_auth_settings as a singleton: a second entry is a load error. The row is wired through the full config path: schema variant (schemas/resources/mcp_auth_settings.schema.json via dump-schema), snapshot table, etcd loader + watch supervisor (put/delete/clone), declarative filesource and export.
Addresses the independent pre-merge audit of #859: - reject userinfo in resource_url (validate_resource_url + a filesource load error mirroring the OIDC issuer/jwks_uri rule): the URL is published verbatim on the unauthenticated protected-resource-metadata endpoint, so an embedded credential must never activate the surface. - extract claims_rejection_error() and unit-test both arms, pinning the single construction site of JwtInsufficientScope (scope failures must carry the provider's required scopes; bound-claims denials must keep the challenge-less variant). - register the well-known routes with any() and gate GET/HEAD inside the handler, so a dormant environment answers the pre-existing bare 404 for every method (previously non-GET flipped to 405 even while dormant); active non-GET/HEAD now 405s with an Allow header. Tests pin both. - sanitize challenge-header interpolations to RFC 6750 NQCHAR: a space can no longer corrupt the scope list and a control byte loses one character instead of silently dropping the whole WWW-Authenticate header. - deterministic multi-row pick test, and a comment on the middleware's second snapshot load (accepted eventual consistency).
The row is a per-environment singleton, but the resolver used to sort the rows and take the smallest id when it found more than one. No ordering over the ids says which row is current, so a stale or migrated key had a coin-flip chance of supplying the PRM `resource` URI and the audience tokens are validated against. Fail closed instead: more than one row keeps the discovery surface dormant (and warns once) until exactly one remains. The check stays in the resolver rather than the loader because the watch supervisor applies puts incrementally and never re-runs the full-load path, so a duplicate can reach a live snapshot without the loader ever seeing both rows.
Adapts the discovery surface to what landed on main since 2026-07-30: - #871 moved resource strictness out of the structs into a strict/lenient schema pair behind one `resource_root_schema(name, strict)` registry. `mcp_auth_settings` joins that registry, gains a lenient twin for the etcd loader, and drops its own `deny_unknown_fields` so a row carrying a newer cp-api field is reported as partially compatible instead of being dropped. The published schema is byte-identical either way. - `merge_snapshot` / `snapshot_has` replaced the hand-written per-kind loops in the watch supervisor; both destructure `AisixSnapshot` exhaustively, so the new kind is registered in each. - `deny()` in the JWT path took two more arguments; the scope / bound-claim split rides the new signature unchanged. - `ProxyError::JwtInsufficientScope` joins `attempt_reached_upstream`'s exhaustive match as a gateway-side decision (never reached upstream). - `/mcp/{server}` (the single-server endpoint added while this branch sat) moves inside the nested router, so the scoped endpoint's 401 carries the same `WWW-Authenticate` discovery hint — a standard client may connect straight to it. `/passthrough/:provider/*rest` is gone from this block; main routes passthrough through the fallback now.
Lets an MCP client that carries no AISIX credential reach entries an
operator opened for it, as a bound API-key principal — for internal
clients migrating from a gateway that never required one.
Anonymous is the NO-credential path, never a downgrade: a credential
that is offered and fails to authenticate is rejected exactly as before,
including a wrong scheme or an empty value. Only a request offering
nothing consults the configuration.
The settings live on the environment's `mcp_auth_settings` row, next to
the OAuth discovery identity (both optional, both off without the row):
anonymous:
api_key_id: <key in this environment> # the principal
source_cidrs: ["10.0.0.0/8"] # required, non-empty
servers: ["docs"] # /mcp/{server} entries
aggregate_entry: false # /mcp, opt-in
`servers` is the entry gate AND the principal's ceiling: it becomes an
extra conjunctive layer on the resolved `ToolAcl`, so an anonymous
caller cannot reach an unlisted server by naming `<server>__<tool>` on
the aggregated endpoint while its scoped entry is closed. `tools/list`
and `tools/call` go through the same `permits`, so listing and calling
agree by construction.
Everything downstream is untouched: the principal's MCP grant, per-server
and per-key rate limits, budget, guardrails and usage attribution all
apply, so anonymous traffic stays governed rather than bypassing the
pipeline.
Refusals answer the same 401 the endpoint gave before the feature
existed — source outside the allowlist, principal deleted/disabled/
expired, block disabled, entry not listed — so an anonymous prober
cannot tell them apart, nor tell a registered server from an unknown
one. The reason lands on `aisix_auth_decisions_total{method="anonymous"}`
where an operator can see it.
The aggregated entry is opt-in because it is where a standard MCP client
starts OAuth discovery: serving it anonymously suppresses the 401 that
carries the `WWW-Authenticate` hint. Scoped entries are the natural home
for migrating clients anyway — original tool names, no namespace.
Also: `resource_url` becomes optional (an environment may configure only
anonymous access), and the CIDR match passthrough routes already used
moves to `client_ip::ip_in_cidrs` so both callers share one closed-on-
bad-input primitive.
Refs api7/AISIX-Cloud#1313
E2E against a real gateway + etcd + two real MCP upstreams. The bound principal deliberately holds a wildcard tool grant, so everything that keeps an anonymous caller away from the unlisted server comes from the anonymous configuration rather than the key — which is what makes the ceiling assertion meaningful. Verified by removing `narrowed_to_servers` and watching `kb__*` appear in the anonymous `tools/list`. Usage attribution: `UsageEvent.auth_type` records `anonymous` when the principal came from an entry's configuration instead of a credential the caller presented. `api_key_id` names the principal either way and cannot tell the two apart once a key doubles as an anonymous principal, which is exactly the case an operator needs to audit. The flag rides on `AuthenticatedKey`, so passthrough routes in `auth_mode: anonymous` — the other entry that mints a principal without a credential — are marked by the same mechanism rather than left as a silent gap in the field's meaning. Older cp-api images ignore the field (DP-first rollout). Refs api7/AISIX-Cloud#1313
…cess # Conflicts: # crates/aisix-core/src/filesource/mod.rs # crates/aisix-core/src/filesource/tests.rs # crates/aisix-core/src/models/mcp_auth_settings.rs # crates/aisix-core/src/models/schema.rs # crates/aisix-proxy/src/lib.rs # crates/aisix-proxy/src/mcp_auth.rs # schemas/resources/mcp_auth_settings.schema.json
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 41 minutes Limit details: You’ve used all 1 included review currently available under your plan. You completed 64 included PR reviews in the past 7 days; at that activity level, included reviews refill at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (17)
Comment |
`servers` is the anonymous principal's ceiling, not just the list of scoped entries to open — so an empty one admits no tool on EITHER entry. The previous coupling accepted `aggregate_entry: true` with no servers, which reads as an enabled anonymous entry and serves nothing: an open door onto an empty room, and the kind of configuration whose failure mode is an operator wondering why anonymous access "does not work". Making the field required says the same rule directly and drops the injected `allOf` entirely. It also states the safe default out loud: a newly registered MCP server is never reachable anonymously until its name is added here.
Fixes api7/AISIX-Cloud#1327
Lets an MCP client that carries no AISIX credential reach entries an operator opened for it, as a bound API-key principal. The driver is a client fleet migrating from a gateway that never required a credential, where changing every client is not on the table.
Shape
The settings live on the environment's
mcp_auth_settingsrow, next to the OAuth discovery identity — both optional, both off without the row, so every existing environment is unchanged:resource_urlbecomes optional as part of this — an environment may configure only anonymous access.Anonymous is the no-credential path, not a downgrade
A request that OFFERS a credential is authenticated, full stop: a bad, expired, disabled or malformed one is rejected exactly as before. Only a request offering nothing at all consults the configuration. "Offers" is deliberately wider than "presents a parseable bearer" — a wrong scheme or an empty value counts, so a caller that tried to authenticate and got it wrong cannot silently land on the anonymous principal's grant instead of its own.
The server list is a ceiling, not just a gate
serversbecomes an extra conjunctive layer on the resolvedToolAcl. Without that, a principal whose own grant is wider than the list could name<unlisted>__<tool>on the aggregated endpoint and reach a server whose scoped entry is closed —/mcp/kbshut, aggregated/mcpopen.tools/listandtools/callboth go through the samepermits, and the scoped endpoint evaluates the namespaced form too, so listing and calling agree by construction across both endpoints.Governance is inherited, not bypassed
The principal is an ordinary API key, so its MCP grant, per-server and per-key rate limits, budget, guardrails and usage attribution all apply unchanged — the anonymous path resolves an
AuthenticatedKeyand hands it to the same pipeline rather than skipping steps.UsageEvent.auth_typerecordsanonymouswhen the principal came from an entry's configuration instead of a credential the caller presented;api_key_idnames the principal either way and cannot tell the two apart once a key doubles as an anonymous principal. The flag rides onAuthenticatedKey, so passthrough routes inauth_mode: anonymous— the other entry that mints a principal without a credential — are marked by the same mechanism instead of leaving a hole in the field's meaning. Older cp-api images ignore the field (DP-first rollout).Refusals are indistinguishable
Source outside the allowlist, principal deleted/disabled/expired, block disabled, entry not listed, server unknown — all answer the same 401 the endpoint gave before this feature existed, so an anonymous prober can neither tell them apart nor map the registered server set by telling 401 from 404. The reason lands on
aisix_auth_decisions_total{method="anonymous"}where an operator can see it and a caller cannot.Why the aggregated entry is opt-in
It is where a standard MCP client starts OAuth discovery: serving it anonymously suppresses the 401 that carries the
WWW-Authenticatehint, so those clients would silently stay on the anonymous grant instead of logging in. Scoped entries are also the better fit for migrating clients — original tool names, no<server>__namespace.Behavior changes
None for an environment without an
anonymousblock. Authentication for/mcpmoves from an extractor to the handler (the decision now depends on which entry was addressed); a rejection still short-circuits without an access log or request metric, as the extractor's 401 did.Not in scope
Per-IP rate limiting and entry-level metering of
initialize/ping/tools/listare tracked separately on the parent issue. With those absent,source_cidrsis mandatory and the feature is positioned for trusted networks rather than public exposure.Testing
kb__*appear in the anonymoustools/list.client_ip::ip_in_cidrs, closed on bad input, with passthrough routes as the second caller.Fixes api7/AISIX-Cloud#1313