Skip to content

feat(mcp): anonymous access to named /mcp entries - #989

Merged
jarvis9443 merged 9 commits into
mainfrom
feat/mcp-anonymous-access
Aug 18, 2026
Merged

feat(mcp): anonymous access to named /mcp entries#989
jarvis9443 merged 9 commits into
mainfrom
feat/mcp-anonymous-access

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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_settings row, next to the OAuth discovery identity — both optional, both off without the row, so every existing environment is unchanged:

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

resource_url becomes 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

servers becomes an extra conjunctive layer on the resolved ToolAcl. 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/kb shut, aggregated /mcp open. tools/list and tools/call both go through the same permits, 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 AuthenticatedKey and hands it to the same pipeline rather than skipping steps.

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. 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 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-Authenticate hint, 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 anonymous block. Authentication for /mcp moves 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/list are tracked separately on the parent issue. With those absent, source_cidrs is mandatory and the feature is positioned for trusted networks rather than public exposure.

Testing

  • Rust unit coverage for the auth decision matrix (credential offered/absent/bad × entry listed/unlisted/aggregated × source in/out of CIDR × principal missing/disabled/expired), the ACL ceiling's intersection semantics, and the usage marking.
  • E2E against a real gateway + etcd + two real MCP upstreams. The bound principal deliberately holds a wildcard tool grant, so everything keeping an anonymous caller away from the unlisted server comes from the anonymous configuration rather than the key. Verified by removing the ceiling and watching kb__* appear in the anonymous tools/list.
  • The shared CIDR match moves to client_ip::ip_in_cidrs, closed on bad input, with passthrough routes as the second caller.

Fixes api7/AISIX-Cloud#1313

membphis and others added 8 commits July 30, 2026 10:53
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
@nic-6443
nic-6443 requested a lite review from Copilot August 18, 2026 11:32
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

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.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0c86c56-94bf-40a6-941b-583cdce4bc98

📥 Commits

Reviewing files that changed from the base of the PR and between 274a084 and 10b6caf.

📒 Files selected for processing (17)
  • 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-mcp/src/gateway.rs
  • crates/aisix-obs/src/usage.rs
  • crates/aisix-proxy/src/auth.rs
  • crates/aisix-proxy/src/client_ip.rs
  • crates/aisix-proxy/src/jwt.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/mcp_auth.rs
  • crates/aisix-proxy/src/passthrough_route.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-proxy/src/usage_attr.rs
  • schemas/resources/mcp_auth_settings.schema.json
  • tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

`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.
@jarvis9443
jarvis9443 merged commit 3a8325b into main Aug 18, 2026
15 checks passed
@jarvis9443
jarvis9443 deleted the feat/mcp-anonymous-access branch August 18, 2026 12:07
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.

3 participants