Skip to content

feat(mcp): layered MCP access policies — env default, team entitlement, key narrowing - #822

Merged
jarvis9443 merged 3 commits into
mainfrom
feat/mcp-access-policy
Jul 27, 2026
Merged

feat(mcp): layered MCP access policies — env default, team entitlement, key narrowing#822
jarvis9443 merged 3 commits into
mainfrom
feat/mcp-access-policy

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1034

Problem

MCP tool access is configured only on individual API keys (allowed_tools). For a fleet of hundreds or thousands of caller keys that means per-key configuration, repeated edits whenever an MCP server or tool is added, and inevitable permission drift. The linked issue asks for team-level entitlements inherited by member keys, key-level narrowing that can never widen, and an environment-wide default with an explicit — never implicit — "all current and future tools" mode.

What this adds

A layered MCP access policy, resolved per request into the same single tool ACL that already backs tools/list filtering and tools/call rejection:

env default policy  →  team policy (replaces env for member keys)  →  key mcp_access
effective = base_grant ∩ key_restriction − deny_union
  • New etcd resource kind mcp_policies: {scope: env|team, scope_ref, mode: none|selected|all, allow[], deny[], expires_at?, enabled}. One env-default row per environment plus one row per team; the control plane is the writer and enforces uniqueness (the DP still resolves duplicates deterministically). all covers current and future tools and is a deliberate mode, not a default.
  • New optional API-key field mcp_access: {mode: inherit|restrict|deny, allow[], deny[]}. inherit uses the inherited grant unchanged, restrict intersects the key's own patterns on top (narrow-only), deny grants nothing.
  • Deny is a global union: deny patterns from the env policy, the key's team policy, and the key itself always subtract — an env-level deny survives a team policy takeover and also applies to legacy keys.
  • Backward compatibility: a key without mcp_access keeps the exact legacy allowed_tools allow side (no inheritance, null/empty = no access, single-* globs, server__tool namespace — all unchanged and still pinned by the existing tests). Policies can only narrow an unmigrated key, never widen it, so upgrading cannot silently grant access.
  • Disabled or expired policies neither grant nor deny (strict-< expiry, same boundary as key expiry).

Prior-art comparison

Surveyed how established gateways shape MCP/tool entitlements before landing on this design:

  • A widely deployed LLM proxy resolves key/team MCP grants as intersection-with-inheritance (empty level = "does not restrict") with an org-level ceiling, deny-by-default for MCP, allowlist-only (no deny patterns), and per-server bare-tool-name lists. Its implicit "empty = inherit" convention forced a global strictness flag plus a "no servers" sentinel value to express opt-outs.
  • An API gateway's MCP tool ACLs let consumer groups inherit a global/default ACL with per-tool exceptions layered on top.
  • An AI gateway platform resolves MCP permissions per user/team/service account, covering both whole servers and individual tools.
  • A major remote MCP server offering ships explicit default/all/read-only/tool-exclusion modes rather than implicit grants.

Where this lands: the same narrow-only inheritance direction as the proxy baseline, but with explicit modes at every level (none|selected|all, inherit|restrict|deny) instead of overloaded empty-list semantics — required anyway because our legacy contract already assigns "empty = no access" — plus deny patterns and an environment-scoped default object, which the issue explicitly calls for (deny overrides, env-wide grants with preview). The server__* glob pattern language is unchanged from the existing allowed_tools contract.

Cross-plane pairing

Per the config-knob rule this DP change is half of the feature; the paired control-plane PR (env policy + team entitlement APIs, projection, migration preview/apply, effective-permissions view, dashboard) follows against api7/AISIX-Cloud, plus a docs PR against api7/docs.

Upgrade order note: the API-key document schema is closed (deny_unknown_fields), so a key carrying the new mcp_access field is rejected (fail-closed, 401 for that key) by a pre-upgrade data plane. The control plane only writes the field once an operator configures it, and the standard release ships both planes together, so the exposure is the usual brief cross-plane window.

Tests

  • aisix-mcp resolution-semantics matrix (12 cases: legacy parity, deny overlay on legacy keys, mode combinations, team takeover + surviving env deny, restrict narrowing, inactive policies, deterministic duplicate handling) plus an HTTP-level gateway case.
  • aisix-proxy endpoint tests: deny mode rejected at the ACL (before routing), env-policy deny overlaying a legacy wildcard key.
  • TS e2e (tests/e2e/src/cases/mcp-access-policy-e2e.test.ts): real binary + etcd + two real MCP upstreams built on the official TypeScript SDK (interop for the ephemeral rmcp client), 8 cases covering every mode plus watch-path propagation of policy edits/deletes. New harness helper startMcpUpstream — the suite's first MCP mock.
  • Schema/serde/validation tests for the new kind and key field; schemas/resources/ regenerated (new mcp_policy.schema.json, extended api_key.schema.json).

Deliberately deferred

The resources-file (standalone) source does not yet accept a top-level mcp_policies: collection, while file-mode api_keys already parse mcp_access — an inherit-mode key in a resources file resolves to zero MCP access until the policy kind lands there (fail-closed). Tracked as #823; until then the layered policy is a managed-mode surface.

…t, key narrowing

New etcd resource kind mcp_policies ({scope: env|team, scope_ref, mode:
none|selected|all, allow, deny, expires_at, enabled}) and a new optional
api_keys field mcp_access ({mode: inherit|restrict|deny, allow, deny}).

The /mcp endpoint now resolves the caller's effective tool ACL from the
key together with the environment-default and team policies:

- base grant = the key team's active policy, else the env default;
- inherit uses it unchanged, restrict intersects the key's own allow
  patterns on top (narrow-only), deny grants nothing;
- deny patterns are a global union (env + team + key) and always win,
  including over legacy keys;
- keys without an mcp_access block keep the exact legacy allowed_tools
  allow side — policies never widen an unmigrated key;
- disabled/expired policies neither grant nor deny.

tools/list filtering and tools/call rejection share the one resolved
ACL, as before. Legacy allowed_tools semantics (null/empty = no access,
single-* globs, server__tool namespace) are unchanged.
New harness mock: a stateless Streamable HTTP MCP upstream built on the
official TypeScript SDK (echo + reverse tools per server), interop-
testing the gateway's ephemeral rmcp client against the reference
implementation.

Eight cases pin the layered-ACL contract end to end (real binary + etcd
+ two upstreams): legacy scoping, deny-overlay-without-widening on
legacy keys, env-default inherit, team takeover with surviving env
deny, team all-mode, restrict narrowing, deny mode, and watch-path
propagation of policy edits and deletes.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

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: 31 minutes

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: 21cd86dd-e8ce-4714-9cf5-a6a4f9f7f00f

📥 Commits

Reviewing files that changed from the base of the PR and between d1425b4 and 797ebe9.

📒 Files selected for processing (5)
  • crates/aisix-core/src/models/mcp_policy.rs
  • crates/aisix-mcp/src/gateway.rs
  • crates/aisix-mcp/tests/gateway_aggregation.rs
  • schemas/resources/api_key.schema.json
  • schemas/resources/mcp_policy.schema.json
📝 Walkthrough

Walkthrough

Adds environment and team MCP policies, per-key access modes, snapshot propagation, effective ACL resolution, schema support, and unit/integration/E2E coverage for policy-driven tool access.

Changes

MCP policy contracts

Layer / File(s) Summary
Policy models and schemas
crates/aisix-core/src/models/*, schemas/resources/*, crates/aisix-admin/src/openapi.rs
Defines policy and API-key access types, validation rules, generated schemas, and ReDoc variant titles.
Snapshot and etcd propagation
crates/aisix-core/src/models/snapshot.rs, crates/aisix-etcd/src/{loader.rs,supervisor.rs}
Stores mcp_policies in snapshots and handles loading, watch updates, deletion, cloning, and metrics.
Effective ACL enforcement
crates/aisix-mcp/src/gateway.rs, crates/aisix-proxy/src/mcp.rs, crates/aisix-mcp/Cargo.toml
Resolves layered allow/deny permissions from policies and API keys, then applies them during MCP dispatch.
Validation and E2E coverage
crates/aisix-mcp/tests/*, crates/aisix-proxy/src/mcp.rs, tests/e2e/src/*, tests/e2e/package.json
Covers access modes, policy precedence, expiry, propagation, gateway filtering, and live MCP upstream behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: moonming

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MCPProxy
  participant ToolAcl
  participant Snapshot
  participant MCPGateway
  Client->>MCPProxy: Send MCP request
  MCPProxy->>Snapshot: Read key and MCP policies
  MCPProxy->>ToolAcl: Resolve effective ACL
  ToolAcl->>MCPGateway: Apply allow and deny layers
  MCPGateway->>Client: Return tools or reject request
Loading
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements MCP tool policy layering, but #1034 also requires team entitlements for models/agents, IdP group mapping, and an effective-permissions view. Add the missing team entitlement surfaces, group-to-team mapping, and effective-permissions view, or narrow the issue scope to the MCP tool-policy subset.
✅ Passed checks (5 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The changes stay focused on MCP policy resolution, schema/validation, loader/supervisor updates, and tests; no unrelated functionality is introduced.
E2e Test Quality Review ✅ Passed E2E covers real gateway+etcd+MCP upstreams, clear scenarios, and no blocking quality/correctness issues found.
Security Check ✅ Passed No issues found in categories 1-7; the new MCP policy/ACL code carries no secrets and denies/restricts access before routing.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: layered MCP access policies with env, team, and key-level resolution.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mcp-access-policy

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (4)
crates/aisix-mcp/src/gateway.rs (1)

190-194: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

deny mode discards the accumulated policy deny list.

Functionally equivalent today (the empty allow layer already rejects everything), but it makes the branch depend on permits never being reworked to short-circuit on deny. Reusing the already-built deny keeps the invariant local.

♻️ Optional tightening
-            McpAccessMode::Deny => Self {
-                allow: vec![AllowLayer::Patterns(Vec::new())],
-                deny: Vec::new(),
-            },
+            McpAccessMode::Deny => Self {
+                allow: vec![AllowLayer::Patterns(Vec::new())],
+                deny,
+            },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-mcp/src/gateway.rs` around lines 190 - 194, Update the
McpAccessMode::Deny branch in the access-policy construction to reuse the
already accumulated deny list instead of initializing deny to an empty vector.
Preserve the rejecting empty allow layer while retaining the existing deny
entries locally.
tests/e2e/src/cases/mcp-access-policy-e2e.test.ts (1)

345-374: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test mutates shared suite state and only passes because it runs last.

It rewrites T1_POLICY_ID to mode: "none" and deletes ENV_POLICY_ID, invalidating the steady state that beforeAll established for every other test in the file. Nothing restores it, so any test appended after this one — or a runner configured to shuffle within a file — breaks. Restoring both rows at the end (and re-waiting for propagation) makes the suite order-independent.

♻️ Restore the seeded state after the mutation checks
     const restored = await callTool(KEY_LEGACY_WILD, "beta__reverse", "hi");
     expect(restored).toEqual({ ok: true, text: "ih" });
     await expectList(KEY_INHERIT, []);
+
+    // Restore the suite's steady state so this test carries no ordering
+    // dependency for anything appended after it.
+    await seed.update("mcp_policies", ENV_POLICY_ID, {
+      scope: "env",
+      mode: "selected",
+      allow: ["alpha__*"],
+      deny: ["beta__reverse"],
+    });
+    await seed.update("mcp_policies", T1_POLICY_ID, {
+      scope: "team",
+      scope_ref: TEAM1,
+      mode: "selected",
+      allow: ["beta__*"],
+    });
+    await waitConfigPropagation(async () => {
+      for (const [token, names] of EXPECTED) {
+        if (!(await listMatches(token, names))) return false;
+      }
+      return true;
+    });
   });

As per coding guidelines, "Avoid explicit dependencies between tests and hidden execution order assumptions".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/mcp-access-policy-e2e.test.ts` around lines 345 - 374,
Restore the shared policy state at the end of the “policy edits and deletes
propagate through the watch path” test: update T1_POLICY_ID back to its
beforeAll mode and recreate ENV_POLICY_ID with its original seeded values. Wait
for configuration propagation and verify the restored key behavior before the
test completes, so subsequent tests do not depend on execution order.

Source: Coding guidelines

tests/e2e/src/harness/upstream-mcp.ts (2)

109-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Swallowing the error makes upstream failures undebuggable.

A malformed body, SDK error, or transport failure becomes a bare 500 with no trace; the E2E assertion then fails on a tool-name mismatch with no root cause. Binding and logging the error costs nothing in a harness.

♻️ Log before responding
-  } catch {
-    if (!res.headersSent) res.writeHead(500).end();
+  } catch (err) {
+    console.error(`mcp upstream ${label}: request failed`, err);
+    if (!res.headersSent) res.writeHead(500).end();
   }

As per coding guidelines, "Every function return value must be checked for errors (if applicable); errors must be properly handled, not ignored or silently swallowed".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/harness/upstream-mcp.ts` around lines 109 - 111, Update the
catch block in the upstream request handler to bind the caught error and log its
details before sending the 500 response. Preserve the existing !res.headersSent
guard and response behavior while ensuring malformed bodies, SDK errors, and
transport failures are no longer silently swallowed.

Source: Coding guidelines


45-49: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Force-close pooled sockets here httpServer.close() waits for keep-alive connections, so this teardown can stall if the MCP client keeps a socket open. Call closeAllConnections() after close() to make shutdown deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/harness/upstream-mcp.ts` around lines 45 - 49, Update the
teardown callback in the upstream MCP harness to force-close pooled sockets
after invoking httpServer.close(), using httpServer.closeAllConnections() so
shutdown does not wait on keep-alive connections. Preserve the existing Promise
resolution behavior and close callback flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-core/src/models/mcp_policy.rs`:
- Around line 113-135: Replace the doc comment immediately above McpAccessMode
with public API reference text describing the enum and its Inherit, Restrict,
and Deny variants; leave McpAccess’s existing comment unchanged. Regenerate the
affected resource schemas using the dump-schema command so the corrected enum
description appears in the generated API documentation.

---

Nitpick comments:
In `@crates/aisix-mcp/src/gateway.rs`:
- Around line 190-194: Update the McpAccessMode::Deny branch in the
access-policy construction to reuse the already accumulated deny list instead of
initializing deny to an empty vector. Preserve the rejecting empty allow layer
while retaining the existing deny entries locally.

In `@tests/e2e/src/cases/mcp-access-policy-e2e.test.ts`:
- Around line 345-374: Restore the shared policy state at the end of the “policy
edits and deletes propagate through the watch path” test: update T1_POLICY_ID
back to its beforeAll mode and recreate ENV_POLICY_ID with its original seeded
values. Wait for configuration propagation and verify the restored key behavior
before the test completes, so subsequent tests do not depend on execution order.

In `@tests/e2e/src/harness/upstream-mcp.ts`:
- Around line 109-111: Update the catch block in the upstream request handler to
bind the caught error and log its details before sending the 500 response.
Preserve the existing !res.headersSent guard and response behavior while
ensuring malformed bodies, SDK errors, and transport failures are no longer
silently swallowed.
- Around line 45-49: Update the teardown callback in the upstream MCP harness to
force-close pooled sockets after invoking httpServer.close(), using
httpServer.closeAllConnections() so shutdown does not wait on keep-alive
connections. Preserve the existing Promise resolution behavior and close
callback flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fbd3e63e-beaf-427f-8b57-7140c8222692

📥 Commits

Reviewing files that changed from the base of the PR and between b369062 and d1425b4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • crates/aisix-admin/src/openapi.rs
  • crates/aisix-core/src/bin/dump-schema.rs
  • crates/aisix-core/src/models/apikey.rs
  • crates/aisix-core/src/models/mcp_policy.rs
  • crates/aisix-core/src/models/mod.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-core/src/models/snapshot.rs
  • crates/aisix-etcd/src/loader.rs
  • crates/aisix-etcd/src/supervisor.rs
  • crates/aisix-mcp/Cargo.toml
  • crates/aisix-mcp/src/gateway.rs
  • crates/aisix-mcp/tests/gateway_aggregation.rs
  • crates/aisix-proxy/src/mcp.rs
  • schemas/resources/api_key.schema.json
  • schemas/resources/mcp_policy.schema.json
  • tests/e2e/package.json
  • tests/e2e/src/cases/mcp-access-policy-e2e.test.ts
  • tests/e2e/src/harness/index.ts
  • tests/e2e/src/harness/upstream-mcp.ts

Comment thread crates/aisix-core/src/models/mcp_policy.rs Outdated
…r duplicate rows

- is_active_at: is_none_or form (clippy::nonminimal_bool held the lint
  job red under -D warnings).
- McpAccessMode / McpAccess / McpPolicyScope / McpPolicyMode doc
  comments rewritten as public API reference text: the mode enum no
  longer carries its parent block's description, and rustdoc link
  syntax no longer leaks into the generated schemas (regenerated).
- resolve: deny patterns now union across every active applicable
  policy row, so a deny can never vanish by losing the duplicate-row
  tie-break; the grant side still picks the lowest id.
- Pinned three edge cases: restrict with empty allow, selected with
  empty allow, and a scope_ref-less team row (grants nothing, never
  shadows the env default).

Standalone resources-file support for mcp_policies is deferred to #823.
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