From ae47101e63acf44f460a77fc47fca003f1f4f444 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 27 Jul 2026 11:55:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp):=20layered=20MCP=20access=20polic?= =?UTF-8?q?ies=20=E2=80=94=20env=20default,=20team=20entitlement,=20key=20?= =?UTF-8?q?narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + crates/aisix-admin/src/openapi.rs | 4 + crates/aisix-core/src/bin/dump-schema.rs | 1 + crates/aisix-core/src/models/apikey.rs | 55 ++- crates/aisix-core/src/models/mcp_policy.rs | 314 +++++++++++++++ crates/aisix-core/src/models/mod.rs | 4 +- crates/aisix-core/src/models/schema.rs | 102 +++++ crates/aisix-core/src/models/snapshot.rs | 6 + crates/aisix-etcd/src/loader.rs | 18 +- crates/aisix-etcd/src/supervisor.rs | 11 + crates/aisix-mcp/Cargo.toml | 2 + crates/aisix-mcp/src/gateway.rs | 196 ++++++++-- crates/aisix-mcp/tests/gateway_aggregation.rs | 370 +++++++++++++++++- crates/aisix-proxy/src/mcp.rs | 104 ++++- schemas/resources/api_key.schema.json | 69 ++++ schemas/resources/mcp_policy.schema.json | 132 +++++++ 16 files changed, 1333 insertions(+), 56 deletions(-) create mode 100644 crates/aisix-core/src/models/mcp_policy.rs create mode 100644 schemas/resources/mcp_policy.schema.json diff --git a/Cargo.lock b/Cargo.lock index c15b3cf5..67693820 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,7 @@ dependencies = [ "aisix-gateway", "async-trait", "axum", + "chrono", "futures", "hex", "http 1.4.0", diff --git a/crates/aisix-admin/src/openapi.rs b/crates/aisix-admin/src/openapi.rs index d7fefd0d..061fe7e1 100644 --- a/crates/aisix-admin/src/openapi.rs +++ b/crates/aisix-admin/src/openapi.rs @@ -4281,6 +4281,10 @@ fn add_variant_titles(doc: &mut Value) { "/components/schemas/KeywordPattern/oneOf", &["Literal", "Regex"], ), + ( + "/components/schemas/McpAccessMode/oneOf", + &["Inherit", "Restrict", "Deny"], + ), ( // Model's top-level direct/routing/ensemble/semantic // mutual-exclusion `oneOf` (injected by diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 60a87d61..e6bd9a4d 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -63,6 +63,7 @@ fn main() { schema::guardrail_attachment_root_schema(), ); dump_value(&out_dir, "mcp_server", schema::mcp_server_root_schema()); + dump_value(&out_dir, "mcp_policy", schema::mcp_policy_root_schema()); dump_value(&out_dir, "a2a_agent", schema::a2a_agent_root_schema()); dump::(&out_dir, "ensemble"); diff --git a/crates/aisix-core/src/models/apikey.rs b/crates/aisix-core/src/models/apikey.rs index 0de17c5f..820fa4be 100644 --- a/crates/aisix-core/src/models/apikey.rs +++ b/crates/aisix-core/src/models/apikey.rs @@ -12,6 +12,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use super::mcp_policy::McpAccess; use super::rate_limit::RateLimit; use crate::resource::Resource; @@ -58,6 +59,16 @@ pub struct ApiKey { #[serde(default, skip_serializing_if = "Option::is_none")] pub allowed_tools: Option>, + /// Policy-driven MCP access for this key. When present, it supersedes + /// `allowed_tools`: the key's grant is computed from the environment's + /// and its team's MCP access policies according to `mode` (`inherit`, + /// `restrict`, or `deny`), and `allowed_tools` is not consulted. When + /// omitted, the key keeps the explicit `allowed_tools` behavior — with + /// policy `deny` patterns still subtracted, since deny applies to every + /// key the policy covers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_access: Option, + /// A2A agents this key may reach, named by their registered names. Entries /// are matched as single-`*` globs, mirroring `allowed_tools`: `"*"` grants /// every agent and an entry without a `*` matches one agent exactly. When @@ -126,9 +137,12 @@ impl ApiKey { /// A key with no `allowed_tools` (or an empty list) may call no MCP tools — /// access is granted explicitly, matching [`ApiKey::can_access`]. /// - /// Currently exercised only by tests: the live MCP enforcement path builds - /// an `aisix_mcp::ToolAcl` from `allowed_tools` and uses the identical - /// matcher, so this method is kept in lockstep as the documented mirror. + /// This mirrors only the legacy allow side (a key without an `mcp_access` + /// block and ignoring policy `deny` overlays). Currently exercised only by + /// tests: the live MCP enforcement path builds an `aisix_mcp::ToolAcl` + /// resolved against the key **and** the environment/team MCP policies, + /// using the identical matcher; this method is kept in lockstep as the + /// documented mirror of its legacy component. pub fn can_access_tool(&self, tool: &str) -> bool { match &self.allowed_tools { None => false, @@ -236,6 +250,7 @@ mod tests { user_id: None, user_name: None, allowed_tools: None, + mcp_access: None, allowed_agents: None, expires_at: None, disabled: false, @@ -303,6 +318,40 @@ mod tests { assert!(!any_server.can_access_tool("github__readonly_admin")); } + #[test] + fn mcp_access_block_roundtrips_and_defaults_absent() { + // Every pre-existing key payload lacks `mcp_access`; it must load + // as None so the legacy allowed_tools behavior keeps applying. + let legacy = sample(); + assert!(legacy.mcp_access.is_none()); + let v = serde_json::to_value(&legacy).unwrap(); + assert!(v.get("mcp_access").is_none()); + + let k: ApiKey = serde_json::from_str( + r#"{ + "key_hash": "h", + "allowed_models": [], + "mcp_access": {"mode": "restrict", "allow": ["github__*"], "deny": ["github__delete_repo"]} + }"#, + ) + .unwrap(); + let access = k.mcp_access.as_ref().unwrap(); + assert_eq!(access.mode, crate::models::McpAccessMode::Restrict); + assert_eq!(access.allow, vec!["github__*"]); + assert_eq!(access.deny, vec!["github__delete_repo"]); + // Round-trip preserves the block. + let v = serde_json::to_value(&k).unwrap(); + assert_eq!(v["mcp_access"]["mode"], "restrict"); + } + + #[test] + fn mcp_access_rejects_unknown_inner_fields() { + let r: Result = serde_json::from_str( + r#"{"key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit","widen":["*"]}}"#, + ); + assert!(r.is_err()); + } + #[test] fn can_access_agent_enforces_allowlist() { // No `allowed_agents` (or null / empty) → no A2A agent access. diff --git a/crates/aisix-core/src/models/mcp_policy.rs b/crates/aisix-core/src/models/mcp_policy.rs new file mode 100644 index 00000000..d886e7ee --- /dev/null +++ b/crates/aisix-core/src/models/mcp_policy.rs @@ -0,0 +1,314 @@ +//! `McpPolicy` entity — environment-default and team-level MCP tool access +//! policies stored in etcd under `mcp_policies/`. +//! +//! Policies lift MCP tool access from per-key `allowed_tools` configuration +//! to a layered grant: an `env`-scoped policy sets the environment-wide +//! default, a `team`-scoped policy replaces that default for the keys that +//! belong to the team, and each key narrows (never widens) the inherited +//! grant through its `mcp_access` block. `deny` patterns from every +//! applicable level are always subtracted, so a tool denied at the +//! environment level stays unavailable regardless of team or key +//! configuration. +//! +//! Keys without an `mcp_access` block keep the pre-policy behavior: their +//! `allowed_tools` list is the entire allow side (no inheritance), with +//! policy `deny` patterns still subtracted. The effective-ACL computation +//! lives with the MCP gateway endpoint, which resolves it per request. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::resource::Resource; + +/// Which API keys a [`McpPolicy`] applies to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpPolicyScope { + /// The environment-wide default, applied to keys whose team has no + /// active policy of its own. + Env, + /// A team-level policy, applied to the keys that belong to the team + /// named by `scope_ref`. It replaces the environment default for those + /// keys. + Team, +} + +/// What a [`McpPolicy`] grants before `deny` patterns are subtracted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpPolicyMode { + /// Grants no MCP tools. + None, + /// Grants exactly the tools matched by the `allow` patterns. + Selected, + /// Grants every tool on every registered MCP server, including servers + /// and tools added after the policy is created. + All, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpPolicy { + /// Which API keys the policy applies to: the whole environment or one + /// team. + pub scope: McpPolicyScope, + + /// Team identifier the policy targets. Required when `scope` is `team`; + /// omitted for an environment-default policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] + pub scope_ref: Option, + + /// What the policy grants: `none`, `selected` (the `allow` patterns), or + /// `all` current and future tools. + pub mode: McpPolicyMode, + + /// Namespaced `__` patterns granted when `mode` is + /// `selected`. Entries are matched as single-`*` globs, the same form as + /// an API key's `allowed_tools`: `"__*"` grants every tool on one + /// server and an entry without a `*` matches one tool exactly. Ignored + /// for the other modes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + + /// Namespaced `__` patterns subtracted from the effective + /// grant of every key the policy applies to, using the same single-`*` + /// glob matching as `allow`. Deny always wins: a tool matched here stays + /// unavailable even when a team policy or a key's own configuration + /// would otherwise grant it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, + + /// RFC 3339 timestamp after which the policy stops applying — it then + /// neither grants nor denies anything. When omitted or set to `null`, + /// the policy never expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + /// Whether the policy is applied. A disabled policy is kept but ignored, + /// exactly like an expired one. Treated as `true` when omitted. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// etcd-key uuid. Filled by the loader and never included in the JSON + /// payload. + #[serde(skip)] + pub(crate) runtime_id: String, +} + +fn default_enabled() -> bool { + true +} + +impl McpPolicy { + /// True if the policy participates in effective-ACL resolution at `now`: + /// enabled and not past its `expires_at` deadline. The expiry comparison + /// is strict (`<`), matching API-key expiry: the policy still applies at + /// the deadline instant itself. + pub fn is_active_at(&self, now: DateTime) -> bool { + self.enabled && !self.expires_at.is_some_and(|deadline| deadline < now) + } +} + +/// Per-key MCP access block, carried on the API key resource. Selects how the +/// key combines with the environment's and its team's [`McpPolicy`] rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpAccessMode { + /// The key uses the inherited grant unchanged: its team's active policy + /// when one exists, otherwise the environment-default policy. + Inherit, + /// The key uses the intersection of the inherited grant and its own + /// `allow` patterns — a restriction can only narrow what the policies + /// grant, never widen it. + Restrict, + /// The key has no MCP tool access at all. + Deny, +} + +/// Policy-driven MCP access configuration on an API key. When present, this +/// block supersedes the key's legacy `allowed_tools` list: the allow side of +/// the key's effective grant is computed from the applicable +/// [`McpPolicy`] rows and `mode`, and `allowed_tools` is not consulted. +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpAccess { + /// How the key combines with the applicable policies: `inherit`, + /// `restrict`, or `deny`. + pub mode: McpAccessMode, + + /// Namespaced `__` patterns intersected with the inherited + /// grant when `mode` is `restrict`, using the same single-`*` glob + /// matching as `allowed_tools`. Ignored for the other modes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + + /// Namespaced `__` patterns subtracted from the key's + /// effective grant, using the same single-`*` glob matching as `allow`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, +} + +impl Resource for McpPolicy { + fn id(&self) -> &str { + &self.runtime_id + } + + /// The by-name index key: the targeted team id, or `"env"` for the + /// environment-default policy. Lookups during effective-ACL resolution + /// iterate and filter on `(scope, scope_ref)` rather than relying on + /// this index, so a malformed row can never shadow the default. + #[allow(clippy::misnamed_getters)] + fn name(&self) -> &str { + self.scope_ref.as_deref().unwrap_or("env") + } + + fn kind() -> &'static str { + "mcp_policies" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialises_env_default_policy() { + let p: McpPolicy = serde_json::from_str( + r#"{ + "scope": "env", + "mode": "selected", + "allow": ["github__*", "postgres__query"], + "deny": ["github__delete_repository"] + }"#, + ) + .unwrap(); + assert_eq!(p.scope, McpPolicyScope::Env); + assert!(p.scope_ref.is_none()); + assert_eq!(p.mode, McpPolicyMode::Selected); + assert_eq!(p.allow, vec!["github__*", "postgres__query"]); + assert_eq!(p.deny, vec!["github__delete_repository"]); + assert!(p.expires_at.is_none()); + assert!(p.enabled); + } + + #[test] + fn deserialises_team_policy() { + let p: McpPolicy = serde_json::from_str( + r#"{ + "scope": "team", + "scope_ref": "team-uuid-1", + "mode": "all" + }"#, + ) + .unwrap(); + assert_eq!(p.scope, McpPolicyScope::Team); + assert_eq!(p.scope_ref.as_deref(), Some("team-uuid-1")); + assert_eq!(p.mode, McpPolicyMode::All); + assert!(p.allow.is_empty()); + assert!(p.deny.is_empty()); + } + + #[test] + fn rejects_unknown_fields() { + let r: Result = + serde_json::from_str(r#"{"scope":"env","mode":"all","extra":1}"#); + assert!(r.is_err()); + } + + #[test] + fn rejects_unknown_mode_and_scope() { + assert!(serde_json::from_str::(r#"{"scope":"org","mode":"all"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"scope":"env","mode":"open"}"#).is_err()); + } + + #[test] + fn is_active_honors_enabled_and_expiry() { + let active: McpPolicy = serde_json::from_str(r#"{"scope":"env","mode":"all"}"#).unwrap(); + let now = chrono::Utc::now(); + assert!(active.is_active_at(now)); + + let disabled: McpPolicy = + serde_json::from_str(r#"{"scope":"env","mode":"all","enabled":false}"#).unwrap(); + assert!(!disabled.is_active_at(now)); + + let expiring: McpPolicy = serde_json::from_str( + r#"{"scope":"env","mode":"all","expires_at":"2030-01-01T00:00:00Z"}"#, + ) + .unwrap(); + let before = "2029-12-31T23:59:59Z".parse().unwrap(); + let at = "2030-01-01T00:00:00Z".parse().unwrap(); + let after = "2030-01-01T00:00:01Z".parse().unwrap(); + assert!(expiring.is_active_at(before)); + // Strict comparison: still applies at the deadline instant itself, + // inactive strictly after it (same boundary as API-key expiry). + assert!(expiring.is_active_at(at)); + assert!(!expiring.is_active_at(after)); + } + + #[test] + fn rejects_malformed_expires_at() { + // A non-RFC3339 string must fail deserialization so the loader + // rejects the row instead of silently treating the policy as + // never-expiring. + let r: Result = + serde_json::from_str(r#"{"scope":"env","mode":"all","expires_at":"tomorrow"}"#); + assert!(r.is_err()); + } + + #[test] + fn resource_trait_points_at_scope_ref_and_kind() { + assert_eq!(McpPolicy::kind(), "mcp_policies"); + + let mut env: McpPolicy = serde_json::from_str(r#"{"scope":"env","mode":"all"}"#).unwrap(); + env.runtime_id = "p-env".into(); + assert_eq!(env.id(), "p-env"); + assert_eq!(env.name(), "env"); + + let mut team: McpPolicy = + serde_json::from_str(r#"{"scope":"team","scope_ref":"team-uuid-1","mode":"none"}"#) + .unwrap(); + team.runtime_id = "p-team".into(); + assert_eq!(team.name(), "team-uuid-1"); + } + + #[test] + fn mcp_access_deserialises_all_modes() { + let inherit: McpAccess = serde_json::from_str(r#"{"mode":"inherit"}"#).unwrap(); + assert_eq!(inherit.mode, McpAccessMode::Inherit); + assert!(inherit.allow.is_empty()); + assert!(inherit.deny.is_empty()); + + let restrict: McpAccess = serde_json::from_str( + r#"{"mode":"restrict","allow":["github__*"],"deny":["github__delete_repository"]}"#, + ) + .unwrap(); + assert_eq!(restrict.mode, McpAccessMode::Restrict); + assert_eq!(restrict.allow, vec!["github__*"]); + assert_eq!(restrict.deny, vec!["github__delete_repository"]); + + let deny: McpAccess = serde_json::from_str(r#"{"mode":"deny"}"#).unwrap(); + assert_eq!(deny.mode, McpAccessMode::Deny); + } + + #[test] + fn mcp_access_rejects_unknown_fields_and_modes() { + assert!(serde_json::from_str::(r#"{"mode":"inherit","extra":1}"#).is_err()); + assert!(serde_json::from_str::(r#"{"mode":"legacy"}"#).is_err()); + } + + #[test] + fn empty_lists_stay_off_the_wire() { + let p: McpPolicy = serde_json::from_str(r#"{"scope":"env","mode":"all"}"#).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert!(v.get("allow").is_none()); + assert!(v.get("deny").is_none()); + assert!(v.get("scope_ref").is_none()); + assert!(v.get("expires_at").is_none()); + + let a: McpAccess = serde_json::from_str(r#"{"mode":"inherit"}"#).unwrap(); + let v = serde_json::to_value(&a).unwrap(); + assert!(v.get("allow").is_none()); + assert!(v.get("deny").is_none()); + } +} diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index 0a0beecc..188a487c 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -21,6 +21,7 @@ pub mod cache_policy; pub mod embedding; pub mod ensemble; pub mod guardrail; +pub mod mcp_policy; pub mod mcp_server; pub mod model; pub mod observability_exporter; @@ -45,6 +46,7 @@ pub use guardrail::{ GuardrailScopeType, KeywordConfig, KeywordPattern, LakeraConfig, OpenaiModerationConfig, PiiConfig, PiiCustomPattern, PiiDetectorConfig, PresidioConfig, PresidioEntityConfig, }; +pub use mcp_policy::{McpAccess, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope}; pub use mcp_server::{McpAuthType, McpServer, McpTransport}; pub use model::{ Adapter, BackgroundModelCheck, CooldownConfig, Model, DEFAULT_COOLDOWN_TRIGGER_STATUSES, @@ -62,7 +64,7 @@ pub use rate_limit_policy::{PolicyScope, PolicyWindow, RateLimitPolicy}; pub use routing::{Routing, RoutingStrategy, RoutingTarget, WhenAllUnavailablePolicy}; pub use schema::{ validate_a2a_agent, validate_apikey, validate_cache_policy, validate_guardrail, - validate_guardrail_attachment, validate_mcp_server, validate_model, + validate_guardrail_attachment, validate_mcp_policy, validate_mcp_server, validate_model, validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, SchemaError, }; diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index 6cfa224f..b76d4700 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -31,6 +31,7 @@ pub struct Schemas { pub observability_exporter: Validator, pub rate_limit_policy: Validator, pub mcp_server: Validator, + pub mcp_policy: Validator, pub a2a_agent: Validator, } @@ -66,6 +67,9 @@ impl Schemas { mcp_server: jsonschema::options() .build(&mcp_server_root_schema()) .expect("mcp_server schema is well-formed"), + mcp_policy: jsonschema::options() + .build(&mcp_policy_root_schema()) + .expect("mcp_policy schema is well-formed"), a2a_agent: jsonschema::options() .build(&a2a_agent_root_schema()) .expect("a2a_agent schema is well-formed"), @@ -139,6 +143,10 @@ pub fn validate_a2a_agent(value: &Value) -> Result<(), SchemaError> { validate(&SCHEMAS.a2a_agent, value) } +pub fn validate_mcp_policy(value: &Value) -> Result<(), SchemaError> { + validate(&SCHEMAS.mcp_policy, value) +} + /// Build a resource's canonical JSON Schema from its struct via `schemars`, /// the single source of field shapes and per-field constraints. /// @@ -362,6 +370,35 @@ fn title_single_value_enum_variants( } } +/// Canonical JSON Schema for the `mcp_policy` resource, derived from the +/// [`McpPolicy`](crate::models::McpPolicy) struct. Uses the nullable `Option` +/// representation (`true`) so `scope_ref`/`expires_at` accept an explicit +/// `null` as well as being absent. The `scope`/`mode` closed sets come from +/// the [`McpPolicyScope`](crate::models::McpPolicyScope) / +/// [`McpPolicyMode`](crate::models::McpPolicyMode) enums, plus the one +/// cross-field invariant `schemars` cannot express: a `team`-scoped policy +/// must name its team in `scope_ref` (otherwise the row could shadow the +/// environment default). +pub fn mcp_policy_root_schema() -> Value { + let mut schema = struct_root_schema::(true); + schema + .as_object_mut() + .expect("mcp_policy root schema is a JSON object") + .insert( + "allOf".to_string(), + json!([{ + "if": { + "properties": { "scope": { "const": "team" } } + }, + "then": { + "required": ["scope_ref"], + "properties": { "scope_ref": { "type": "string", "minLength": 1 } } + } + }]), + ); + schema +} + /// Canonical JSON Schema for the `guardrail` resource, derived from the /// [`Guardrail`](crate::models::Guardrail) struct. `schemars` renders the /// internally-tagged `GuardrailKind` as a native top-level `oneOf`; the @@ -1418,6 +1455,71 @@ mod tests { validate_apikey(&v).unwrap(); } + #[test] + fn apikey_mcp_access_block_passes() { + let v = json!({ + "key_hash":"9df37f5e7cbc3c391d872742b5f286c242e733a09add9eeaa4d26a599bd90b20", + "allowed_models":["gpt-4o"], + "mcp_access": {"mode": "inherit"} + }); + validate_apikey(&v).unwrap(); + let v = json!({ + "key_hash":"9df37f5e7cbc3c391d872742b5f286c242e733a09add9eeaa4d26a599bd90b20", + "allowed_models":["gpt-4o"], + "mcp_access": {"mode": "restrict", "allow": ["github__*"], "deny": ["github__delete_repo"]} + }); + validate_apikey(&v).unwrap(); + } + + #[test] + fn apikey_mcp_access_rejects_unknown_mode() { + let v = json!({ + "key_hash":"9df37f5e7cbc3c391d872742b5f286c242e733a09add9eeaa4d26a599bd90b20", + "allowed_models":[], + "mcp_access": {"mode": "legacy"} + }); + assert!(validate_apikey(&v).is_err()); + } + + #[test] + fn mcp_policy_env_and_team_forms_pass() { + validate_mcp_policy(&json!({ + "scope": "env", + "mode": "selected", + "allow": ["github__*"], + "deny": ["github__delete_repo"] + })) + .unwrap(); + validate_mcp_policy(&json!({ + "scope": "team", + "scope_ref": "team-uuid-1", + "mode": "all", + "expires_at": "2030-01-01T00:00:00Z", + "enabled": true + })) + .unwrap(); + } + + #[test] + fn mcp_policy_team_scope_requires_scope_ref() { + // A team row without its team id could shadow the environment + // default; the cross-field guard rejects it at the schema gate. + assert!(validate_mcp_policy(&json!({"scope": "team", "mode": "all"})).is_err()); + assert!( + validate_mcp_policy(&json!({"scope": "team", "scope_ref": null, "mode": "all"})) + .is_err() + ); + // The environment default carries no scope_ref. + validate_mcp_policy(&json!({"scope": "env", "mode": "none"})).unwrap(); + } + + #[test] + fn mcp_policy_rejects_unknown_fields_and_values() { + assert!(validate_mcp_policy(&json!({"scope": "org", "mode": "all"})).is_err()); + assert!(validate_mcp_policy(&json!({"scope": "env", "mode": "open"})).is_err()); + assert!(validate_mcp_policy(&json!({"scope": "env", "mode": "all", "rogue": 1})).is_err()); + } + #[test] fn apikey_unknown_field_rejected() { let v = json!({ diff --git a/crates/aisix-core/src/models/snapshot.rs b/crates/aisix-core/src/models/snapshot.rs index 1091acae..8a4f79c3 100644 --- a/crates/aisix-core/src/models/snapshot.rs +++ b/crates/aisix-core/src/models/snapshot.rs @@ -8,6 +8,7 @@ use super::a2a_agent::A2aAgent; use super::apikey::ApiKey; use super::cache_policy::CachePolicy; use super::guardrail::{Guardrail, GuardrailAttachment}; +use super::mcp_policy::McpPolicy; use super::mcp_server::McpServer; use super::model::Model; use super::observability_exporter::ObservabilityExporter; @@ -40,6 +41,10 @@ pub struct AisixSnapshot { /// MCP gateway endpoint aggregates each enabled server's tools and routes /// tool calls back to the owning server. pub mcp_servers: ResourceTable, + /// MCP access policies: `/aisix//mcp_policies/`. Environment- + /// default and team-scoped rows the MCP gateway endpoint combines with + /// each caller key's `mcp_access` block into the per-request tool ACL. + pub mcp_policies: ResourceTable, /// Registered upstream A2A agents: `/aisix//a2a_agents/`. The /// A2A gateway endpoint fronts each enabled agent, forwarding JSON-RPC /// requests to it and serving its card with URLs rewritten to the gateway. @@ -63,6 +68,7 @@ impl AisixSnapshot { + self.observability_exporters.len() + self.rate_limit_policies.len() + self.mcp_servers.len() + + self.mcp_policies.len() + self.a2a_agents.len() } } diff --git a/crates/aisix-etcd/src/loader.rs b/crates/aisix-etcd/src/loader.rs index 69b6b338..f55d0bf4 100644 --- a/crates/aisix-etcd/src/loader.rs +++ b/crates/aisix-etcd/src/loader.rs @@ -13,10 +13,10 @@ use aisix_core::models::{ validate_a2a_agent, validate_apikey, validate_cache_policy, validate_guardrail, - validate_guardrail_attachment, validate_mcp_server, validate_model, + validate_guardrail_attachment, validate_mcp_policy, validate_mcp_server, validate_model, validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, A2aAgent, - ApiKey, CachePolicy, Guardrail, GuardrailAttachment, McpServer, Model, ObservabilityExporter, - ProviderKey, RateLimitPolicy, SchemaError, + ApiKey, CachePolicy, Guardrail, GuardrailAttachment, McpPolicy, McpServer, Model, + ObservabilityExporter, ProviderKey, RateLimitPolicy, SchemaError, }; use aisix_core::resource::ResourceEntry; use aisix_core::AisixSnapshot; @@ -257,6 +257,18 @@ pub fn build_snapshot(prefix: &str, entries: &[RawEntry]) -> (AisixSnapshot, Bui snapshot.mcp_servers.insert(entry); } } + "mcp_policies" => { + if let Some(entry) = validate_and_parse::( + &raw.key, + raw.revision, + parsed, + &value, + validate_mcp_policy, + &mut stats, + ) { + snapshot.mcp_policies.insert(entry); + } + } "a2a_agents" => { if let Some(entry) = validate_and_parse::( &raw.key, diff --git a/crates/aisix-etcd/src/supervisor.rs b/crates/aisix-etcd/src/supervisor.rs index 3bf9fa43..39a3d7b4 100644 --- a/crates/aisix-etcd/src/supervisor.rs +++ b/crates/aisix-etcd/src/supervisor.rs @@ -480,6 +480,9 @@ impl Supervisor

{ for e in tiny.mcp_servers.entries() { new.mcp_servers.insert(clone_entry(&e)); } + for e in tiny.mcp_policies.entries() { + new.mcp_policies.insert(clone_entry(&e)); + } for e in tiny.a2a_agents.entries() { new.a2a_agents.insert(clone_entry(&e)); } @@ -539,6 +542,7 @@ impl Supervisor

{ } "rate_limit_policies" => snap.rate_limit_policies.get_by_id(parsed.id).is_some(), "mcp_servers" => snap.mcp_servers.get_by_id(parsed.id).is_some(), + "mcp_policies" => snap.mcp_policies.get_by_id(parsed.id).is_some(), "a2a_agents" => snap.a2a_agents.get_by_id(parsed.id).is_some(), _ => false, }; @@ -590,6 +594,9 @@ impl Supervisor

{ "mcp_servers" => { new.mcp_servers.remove(parsed.id); } + "mcp_policies" => { + new.mcp_policies.remove(parsed.id); + } "a2a_agents" => { new.a2a_agents.remove(parsed.id); } @@ -837,6 +844,9 @@ fn clone_snapshot(src: &AisixSnapshot) -> AisixSnapshot { for e in src.mcp_servers.entries() { out.mcp_servers.insert(clone_entry(&e)); } + for e in src.mcp_policies.entries() { + out.mcp_policies.insert(clone_entry(&e)); + } for e in src.a2a_agents.entries() { out.a2a_agents.insert(clone_entry(&e)); } @@ -861,6 +871,7 @@ fn resource_counts(snap: &AisixSnapshot) -> BTreeMap { ), ("rate_limit_policies", snap.rate_limit_policies.len()), ("mcp_servers", snap.mcp_servers.len()), + ("mcp_policies", snap.mcp_policies.len()), ("a2a_agents", snap.a2a_agents.len()), ] { if n > 0 { diff --git a/crates/aisix-mcp/Cargo.toml b/crates/aisix-mcp/Cargo.toml index c0d116f3..4b106013 100644 --- a/crates/aisix-mcp/Cargo.toml +++ b/crates/aisix-mcp/Cargo.toml @@ -16,6 +16,8 @@ aisix-core = { path = "../aisix-core" } aisix-gateway = { path = "../aisix-gateway" } tokio.workspace = true async-trait.workspace = true +# Effective-ACL resolution timestamps policy expiry checks (`ToolAcl::resolve`). +chrono.workspace = true futures.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/aisix-mcp/src/gateway.rs b/crates/aisix-mcp/src/gateway.rs index 55f44419..145f839c 100644 --- a/crates/aisix-mcp/src/gateway.rs +++ b/crates/aisix-mcp/src/gateway.rs @@ -26,6 +26,7 @@ use std::borrow::Cow; use std::sync::Arc; +use chrono::{DateTime, Utc}; use rmcp::model::{ CallToolRequestParams, CallToolResult, Content, ErrorData, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, @@ -35,7 +36,8 @@ use rmcp::transport::streamable_http_server::session::local::LocalSessionManager use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; use rmcp::{RoleServer, ServerHandler}; -use aisix_core::AisixSnapshot; +use aisix_core::models::{ApiKey, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope}; +use aisix_core::{AisixSnapshot, ResourceEntry}; use crate::bridge::{upstream_from_mcp_server, EphemeralBridge, McpBridge}; @@ -51,44 +53,176 @@ struct NamedUpstream { } /// Which tools a gateway instance may expose and call, in the namespaced -/// `__` form. Built per request from the caller's API key so MCP -/// tool access is governed by the same key object as LLM access. +/// `__` form. Built per request from the caller's API key and +/// the environment's / the key's team's MCP access policies, so MCP tool +/// access is governed by the same key object as LLM access. +/// +/// A tool is permitted only when **every** allow layer admits it and **no** +/// deny pattern matches it. A legacy key (no `mcp_access` block) carries a +/// single allow layer built from its `allowed_tools`; a policy-driven key +/// carries the inherited policy grant and, in `restrict` mode, its own +/// `allow` patterns as a second conjunctive layer — a key can narrow the +/// inherited grant but never widen it. Deny patterns are unioned across the +/// environment policy, the team policy, and the key, and always win. #[derive(Clone)] -pub enum ToolAcl { - /// No restriction — every aggregated tool is exposed. - AllowAll, - /// Only these namespaced tool names are exposed; any other is hidden from - /// `tools/list` and rejected by `tools/call`. - Allow(std::collections::HashSet), +pub struct ToolAcl { + /// Conjunctive allow layers: a tool must match every layer. + allow: Vec, + /// Deny patterns; any match rejects the tool, overriding every allow + /// layer. + deny: Vec, } -impl ToolAcl { - /// Build an ACL from an API key's `allowed_tools` list: `None` or an empty - /// list grants no tools; a list containing `"*"` grants all; otherwise the - /// listed patterns. Entries are matched as single-`*` globs (see - /// [`ToolAcl::permits`]), mirroring `ApiKey::can_access_tool`. - pub fn from_allowed(allowed: Option<&[String]>) -> Self { - match allowed { - Some(list) if list.iter().any(|t| t == "*") => Self::AllowAll, - Some(list) => Self::Allow(list.iter().cloned().collect()), - None => Self::Allow(std::collections::HashSet::new()), +#[derive(Clone)] +enum AllowLayer { + /// The layer admits every tool. + All, + /// The layer admits tools matching any of these single-`*` glob patterns. + Patterns(Vec), +} + +impl AllowLayer { + /// A bare `"*"` entry is folded into [`AllowLayer::All`]; every other + /// list stays a pattern set (including the empty list, which admits + /// nothing). + fn from_patterns(patterns: &[String]) -> Self { + if patterns.iter().any(|p| p == "*") { + Self::All + } else { + Self::Patterns(patterns.to_vec()) } } - /// Whether `namespaced_tool` is permitted. Patterns are single-`*` globs: - /// `"__*"` grants every tool on that server, a pattern without a - /// `*` matches exactly. (A bare `"*"` is folded into [`Self::AllowAll`] at - /// construction.) Uses the same matcher as `ApiKey::can_access_tool`. - fn permits(&self, namespaced_tool: &str) -> bool { + fn admits(&self, namespaced_tool: &str) -> bool { match self { - Self::AllowAll => true, - Self::Allow(patterns) => patterns + Self::All => true, + Self::Patterns(patterns) => patterns .iter() .any(|p| aisix_core::wildcard::wildcard_matches(p, namespaced_tool)), } } } +impl ToolAcl { + /// The unrestricted ACL — every aggregated tool is exposed. Gateways + /// start here until scoped with [`McpGateway::with_tool_acl`]. + fn allow_all() -> Self { + Self { + allow: vec![AllowLayer::All], + deny: Vec::new(), + } + } + + /// Build a legacy ACL from an API key's `allowed_tools` list alone: + /// `None` or an empty list grants no tools; a list containing `"*"` + /// grants all; otherwise the listed patterns. Entries are matched as + /// single-`*` globs (see [`ToolAcl::permits`]), mirroring + /// `ApiKey::can_access_tool`. Policy deny overlays are NOT applied here — + /// any caller serving external traffic uses [`ToolAcl::resolve`]. + pub fn from_allowed(allowed: Option<&[String]>) -> Self { + Self { + allow: vec![AllowLayer::from_patterns(allowed.unwrap_or(&[]))], + deny: Vec::new(), + } + } + + /// Resolve the effective ACL for `key` from the `mcp_policies` in + /// `snapshot` at time `now`. + /// + /// - A key without an `mcp_access` block keeps its legacy allow side + /// (`allowed_tools`, no inheritance) — with active policy `deny` + /// patterns still subtracted, since deny applies to every key a policy + /// covers. + /// - `deny` mode grants nothing. + /// - `inherit` / `restrict` take the base grant from the key's team + /// policy when one is active, else the environment-default policy, + /// else nothing; `restrict` intersects the key's own `allow` patterns + /// on top. + /// - Deny patterns are unioned across the environment policy, the team + /// policy, and the key — an environment-level deny holds even when a + /// team policy replaces the environment's grant. + /// + /// Inactive policies (disabled or expired) neither grant nor deny. + pub fn resolve(snapshot: &AisixSnapshot, key: &ApiKey, now: DateTime) -> Self { + // Pick the governing row per scope deterministically (lowest id wins) + // so a duplicated row — the writer enforces uniqueness — can only + // ever produce a stable outcome. + let mut env_policy: Option>> = None; + let mut team_policy: Option>> = None; + for entry in snapshot.mcp_policies.entries() { + if !entry.value.is_active_at(now) { + continue; + } + let slot = match entry.value.scope { + McpPolicyScope::Env => &mut env_policy, + McpPolicyScope::Team => { + let targets_key_team = key.team_id.is_some() + && key.team_id.as_deref() == entry.value.scope_ref.as_deref(); + if !targets_key_team { + continue; + } + &mut team_policy + } + }; + match slot { + Some(current) if current.id <= entry.id => {} + _ => *slot = Some(entry), + } + } + + let mut deny: Vec = Vec::new(); + if let Some(p) = &env_policy { + deny.extend(p.value.deny.iter().cloned()); + } + if let Some(p) = &team_policy { + deny.extend(p.value.deny.iter().cloned()); + } + + let Some(access) = &key.mcp_access else { + return Self { + allow: vec![AllowLayer::from_patterns( + key.allowed_tools.as_deref().unwrap_or(&[]), + )], + deny, + }; + }; + + match access.mode { + McpAccessMode::Deny => Self { + allow: vec![AllowLayer::Patterns(Vec::new())], + deny: Vec::new(), + }, + mode @ (McpAccessMode::Inherit | McpAccessMode::Restrict) => { + let governing = team_policy.as_ref().or(env_policy.as_ref()); + let base = match governing.map(|p| (p.value.mode, &p.value.allow)) { + None | Some((McpPolicyMode::None, _)) => AllowLayer::Patterns(Vec::new()), + Some((McpPolicyMode::All, _)) => AllowLayer::All, + Some((McpPolicyMode::Selected, allow)) => AllowLayer::from_patterns(allow), + }; + let mut allow = vec![base]; + if mode == McpAccessMode::Restrict { + allow.push(AllowLayer::from_patterns(&access.allow)); + } + deny.extend(access.deny.iter().cloned()); + Self { allow, deny } + } + } + } + + /// Whether `namespaced_tool` is permitted: every allow layer must admit + /// it and no deny pattern may match it. Patterns are single-`*` globs: + /// `"__*"` covers every tool on that server, a pattern without a + /// `*` matches exactly, and a bare `"*"` covers everything. Uses the same + /// matcher as `ApiKey::can_access_tool`. + pub fn permits(&self, namespaced_tool: &str) -> bool { + self.allow.iter().all(|layer| layer.admits(namespaced_tool)) + && !self + .deny + .iter() + .any(|p| aisix_core::wildcard::wildcard_matches(p, namespaced_tool)) + } +} + /// Aggregates N upstream MCP servers behind one downstream MCP server surface. /// Cheap to clone (the upstream set is shared); the Streamable HTTP transport /// clones it per session. @@ -107,10 +241,10 @@ impl McpGateway { /// later one and emitting duplicate tool names on the wire. Server names /// must not contain [`TOOL_NAMESPACE_SEPARATOR`]. /// - /// The gateway is **unrestricted** ([`ToolAcl::AllowAll`]) until scoped with - /// [`McpGateway::with_tool_acl`]. Any caller that serves external traffic - /// MUST scope it to the caller's key — the proxy `/mcp` mount is the single - /// enforcement point and always does. + /// The gateway is **unrestricted** (every tool permitted) until scoped + /// with [`McpGateway::with_tool_acl`]. Any caller that serves external + /// traffic MUST scope it to the caller's key — the proxy `/mcp` mount is + /// the single enforcement point and always does, via [`ToolAcl::resolve`]. pub fn new(upstreams: impl IntoIterator)>) -> Self { let mut seen = std::collections::HashSet::new(); let mut deduped = Vec::new(); @@ -131,7 +265,7 @@ impl McpGateway { } Self { upstreams: deduped.into(), - tool_acl: ToolAcl::AllowAll, + tool_acl: ToolAcl::allow_all(), } } diff --git a/crates/aisix-mcp/tests/gateway_aggregation.rs b/crates/aisix-mcp/tests/gateway_aggregation.rs index fc6dd51b..55a7dbc8 100644 --- a/crates/aisix-mcp/tests/gateway_aggregation.rs +++ b/crates/aisix-mcp/tests/gateway_aggregation.rs @@ -487,24 +487,312 @@ async fn from_snapshot_degrades_misconfigured_oauth2_upstream_gracefully() { #[test] fn tool_acl_from_allowed_semantics() { // No allowed_tools / empty → deny all. - assert!(matches!(ToolAcl::from_allowed(None), ToolAcl::Allow(ref s) if s.is_empty())); - assert!(matches!(ToolAcl::from_allowed(Some(&[])), ToolAcl::Allow(ref s) if s.is_empty())); + assert!(!ToolAcl::from_allowed(None).permits("github__create_issue")); + assert!(!ToolAcl::from_allowed(Some(&[])).permits("github__create_issue")); // Wildcard → allow all. - assert!(matches!( - ToolAcl::from_allowed(Some(&["*".to_string()])), - ToolAcl::AllowAll - )); + let all = ToolAcl::from_allowed(Some(&["*".to_string()])); + assert!(all.permits("github__create_issue")); + assert!(all.permits("anything__at_all")); // Exact set. - assert!(matches!( - ToolAcl::from_allowed(Some(&["a__b".to_string()])), - ToolAcl::Allow(_) - )); - // A per-server wildcard is a scoped Allow, not AllowAll — only a bare + let exact = ToolAcl::from_allowed(Some(&["a__b".to_string()])); + assert!(exact.permits("a__b")); + assert!(!exact.permits("a__c")); + // A per-server wildcard is a scoped grant, not allow-all — only a bare // `"*"` opens everything. - assert!(matches!( - ToolAcl::from_allowed(Some(&["github__*".to_string()])), - ToolAcl::Allow(_) - )); + let scoped = ToolAcl::from_allowed(Some(&["github__*".to_string()])); + assert!(scoped.permits("github__create_issue")); + assert!(!scoped.permits("slack__post_message")); +} + +// ---- policy-driven effective-ACL resolution (`ToolAcl::resolve`) ---- + +/// Build a caller key from raw JSON (the etcd document shape). +fn acl_key(json: serde_json::Value) -> aisix_core::models::ApiKey { + serde_json::from_value(json).unwrap() +} + +/// Build a snapshot holding the given `mcp_policies` rows, each `(id, doc)`. +fn policy_snapshot(rows: &[(&str, serde_json::Value)]) -> AisixSnapshot { + let snapshot = AisixSnapshot::new(); + for (id, doc) in rows { + let policy: aisix_core::models::McpPolicy = serde_json::from_value(doc.clone()).unwrap(); + snapshot + .mcp_policies + .insert(ResourceEntry::new(*id, policy, 1)); + } + snapshot +} + +fn resolve_now(snapshot: &AisixSnapshot, key: &aisix_core::models::ApiKey) -> ToolAcl { + ToolAcl::resolve(snapshot, key, chrono::Utc::now()) +} + +#[test] +fn resolve_legacy_key_without_policies_matches_from_allowed() { + let snapshot = AisixSnapshot::new(); + let no_tools = acl_key(serde_json::json!({"key_hash":"h","allowed_models":[]})); + assert!(!resolve_now(&snapshot, &no_tools).permits("github__create_issue")); + + let listed = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"allowed_tools":["github__*"] + })); + let acl = resolve_now(&snapshot, &listed); + assert!(acl.permits("github__create_issue")); + assert!(!acl.permits("slack__post_message")); +} + +#[test] +fn resolve_legacy_key_gets_deny_overlay_but_no_policy_grant() { + // An env policy grants everything and denies one tool. A legacy key + // (no mcp_access block) must NOT be widened by the grant — its + // allowed_tools stays the whole allow side — but the deny overlay + // applies: deny covers every key the policy covers. + let snapshot = policy_snapshot(&[( + "p-env", + serde_json::json!({"scope":"env","mode":"all","deny":["github__delete_repo"]}), + )]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"allowed_tools":["github__*"] + })); + let acl = resolve_now(&snapshot, &key); + assert!(acl.permits("github__create_issue")); + assert!( + !acl.permits("github__delete_repo"), + "policy deny must subtract from a legacy key's grant" + ); + assert!( + !acl.permits("slack__post_message"), + "an env grant must not widen a legacy key" + ); +} + +#[test] +fn resolve_deny_mode_grants_nothing() { + let snapshot = policy_snapshot(&[("p-env", serde_json::json!({"scope":"env","mode":"all"}))]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[], + "allowed_tools":["*"], + "mcp_access":{"mode":"deny"} + })); + let acl = resolve_now(&snapshot, &key); + assert!(!acl.permits("github__create_issue")); + assert!(!acl.permits("anything__at_all")); +} + +#[test] +fn resolve_inherit_takes_env_default() { + let snapshot = policy_snapshot(&[( + "p-env", + serde_json::json!({ + "scope":"env","mode":"selected", + "allow":["github__*"], + "deny":["github__delete_repo"] + }), + )]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit"} + })); + let acl = resolve_now(&snapshot, &key); + assert!(acl.permits("github__create_issue")); + assert!(!acl.permits("github__delete_repo"), "deny beats allow"); + assert!(!acl.permits("slack__post_message")); +} + +#[test] +fn resolve_inherit_env_all_and_none_modes() { + let all = policy_snapshot(&[("p", serde_json::json!({"scope":"env","mode":"all"}))]); + let none = policy_snapshot(&[("p", serde_json::json!({"scope":"env","mode":"none"}))]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit"} + })); + assert!(resolve_now(&all, &key).permits("anything__at_all")); + assert!(!resolve_now(&none, &key).permits("anything__at_all")); +} + +#[test] +fn resolve_inherit_without_any_policy_grants_nothing() { + let snapshot = AisixSnapshot::new(); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit"} + })); + assert!(!resolve_now(&snapshot, &key).permits("github__create_issue")); +} + +#[test] +fn resolve_team_policy_replaces_env_grant_for_member_keys() { + let snapshot = policy_snapshot(&[ + ( + "p-env", + serde_json::json!({"scope":"env","mode":"selected","allow":["slack__*"]}), + ), + ( + "p-team", + serde_json::json!({ + "scope":"team","scope_ref":"team-1","mode":"selected","allow":["github__*"] + }), + ), + ]); + + // A key in team-1 gets the team grant, not the env default. + let member = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"team_id":"team-1", + "mcp_access":{"mode":"inherit"} + })); + let acl = resolve_now(&snapshot, &member); + assert!(acl.permits("github__create_issue")); + assert!( + !acl.permits("slack__post_message"), + "the team policy replaces the env grant, it does not union with it" + ); + + // A key outside any team falls back to the env default. + let unbound = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit"} + })); + let acl = resolve_now(&snapshot, &unbound); + assert!(acl.permits("slack__post_message")); + assert!(!acl.permits("github__create_issue")); + + // A key in a team WITHOUT its own policy also falls back to the env + // default. + let other_team = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"team_id":"team-2", + "mcp_access":{"mode":"inherit"} + })); + assert!(resolve_now(&snapshot, &other_team).permits("slack__post_message")); +} + +#[test] +fn resolve_env_deny_survives_team_takeover() { + // The approved deny semantics: deny patterns are a global union — an + // environment-level deny holds even when a team policy replaces the + // environment's grant with a broader one. + let snapshot = policy_snapshot(&[ + ( + "p-env", + serde_json::json!({ + "scope":"env","mode":"selected","allow":["slack__*"], + "deny":["github__delete_repo"] + }), + ), + ( + "p-team", + serde_json::json!({"scope":"team","scope_ref":"team-1","mode":"all"}), + ), + ]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"team_id":"team-1", + "mcp_access":{"mode":"inherit"} + })); + let acl = resolve_now(&snapshot, &key); + assert!(acl.permits("github__create_issue")); + assert!( + !acl.permits("github__delete_repo"), + "an env-level deny must survive a team policy taking over the grant" + ); +} + +#[test] +fn resolve_restrict_narrows_but_never_widens() { + let snapshot = policy_snapshot(&[("p-env", serde_json::json!({"scope":"env","mode":"all"}))]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[], + "mcp_access":{"mode":"restrict","allow":["github__*"],"deny":["github__delete_repo"]} + })); + let acl = resolve_now(&snapshot, &key); + assert!(acl.permits("github__create_issue")); + assert!( + !acl.permits("slack__post_message"), + "restrict narrows `all`" + ); + assert!(!acl.permits("github__delete_repo"), "key deny subtracts"); + + // Restriction patterns outside the base grant add nothing: base is + // selected [slack__*], the key asks for github — the intersection is + // empty in the github direction and slack is cut by the key layer. + let narrow_base = policy_snapshot(&[( + "p-env", + serde_json::json!({"scope":"env","mode":"selected","allow":["slack__*"]}), + )]); + let acl = resolve_now(&narrow_base, &key); + assert!(!acl.permits("github__create_issue")); + assert!(!acl.permits("slack__post_message")); +} + +#[test] +fn resolve_inherit_ignores_key_allow_patterns() { + // `allow` participates only in restrict mode; an inherit key carrying + // stray allow patterns must not have them narrow (or widen) the grant. + let snapshot = policy_snapshot(&[("p-env", serde_json::json!({"scope":"env","mode":"all"}))]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[], + "mcp_access":{"mode":"inherit","allow":["github__*"]} + })); + assert!(resolve_now(&snapshot, &key).permits("slack__post_message")); +} + +#[test] +fn resolve_ignores_disabled_and_expired_policies() { + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"team_id":"team-1", + "mcp_access":{"mode":"inherit"} + })); + + // Disabled team policy → fall back to the env default. + let snapshot = policy_snapshot(&[ + ( + "p-env", + serde_json::json!({"scope":"env","mode":"selected","allow":["slack__*"]}), + ), + ( + "p-team", + serde_json::json!({ + "scope":"team","scope_ref":"team-1","mode":"all","enabled":false + }), + ), + ]); + let acl = resolve_now(&snapshot, &key); + assert!(acl.permits("slack__post_message")); + assert!(!acl.permits("github__create_issue")); + + // Expired env policy → no grant at all (and its deny stops applying). + let snapshot = policy_snapshot(&[( + "p-env", + serde_json::json!({ + "scope":"env","mode":"all","deny":["slack__*"], + "expires_at":"2000-01-01T00:00:00Z" + }), + )]); + let legacy = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"allowed_tools":["slack__*"] + })); + assert!(!resolve_now(&snapshot, &key).permits("anything__at_all")); + assert!( + resolve_now(&snapshot, &legacy).permits("slack__post_message"), + "an expired policy's deny must stop applying" + ); +} + +#[test] +fn resolve_duplicate_scope_rows_pick_lowest_id() { + // The writer enforces one row per scope; if duplicates ever appear the + // outcome must at least be deterministic — lowest id governs. + let snapshot = policy_snapshot(&[ + ( + "p-b", + serde_json::json!({"scope":"env","mode":"selected","allow":["beta__*"]}), + ), + ( + "p-a", + serde_json::json!({"scope":"env","mode":"selected","allow":["alpha__*"]}), + ), + ]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit"} + })); + let acl = resolve_now(&snapshot, &key); + assert!(acl.permits("alpha__echo")); + assert!(!acl.permits("beta__echo")); } #[tokio::test] @@ -602,6 +890,58 @@ async fn tool_acl_per_server_wildcard_scopes_to_one_server() { .expect_err("a tool outside the granted server must be rejected"); } +#[tokio::test] +async fn policy_resolved_acl_filters_list_and_rejects_calls() { + // Full wiring: an env policy granting alpha only, denied one level up + // by nothing; the key inherits. beta stays hidden from tools/list and + // rejected on tools/call, exactly like a legacy allowlist would. + let snapshot = policy_snapshot(&[( + "p-env", + serde_json::json!({"scope":"env","mode":"selected","allow":["alpha__*"]}), + )]); + let key = acl_key(serde_json::json!({ + "key_hash":"h","allowed_models":[],"mcp_access":{"mode":"inherit"} + })); + let acl = resolve_now(&snapshot, &key); + + let gateway = McpGateway::new([ + ("alpha".to_string(), bridge_to("alpha").await), + ("beta".to_string(), bridge_to("beta").await), + ]) + .with_tool_acl(acl); + let gw_addr = spawn_gateway(gateway).await; + let client = () + .serve(StreamableHttpClientTransport::from_uri(format!( + "http://{gw_addr}/mcp" + ))) + .await + .expect("connect"); + + let tools = client.list_all_tools().await.expect("list tools"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + vec!["alpha__echo"], + "policy-resolved ACL must hide non-granted tools" + ); + + let allowed = client + .call_tool(call("alpha__echo", "hi")) + .await + .expect("granted call"); + assert_eq!(first_text(&allowed), "alpha:hi"); + + let err = client + .call_tool(call("beta__echo", "hi")) + .await + .expect_err("a non-granted tool call must be rejected"); + let msg = format!("{err:?}"); + assert!( + msg.contains("not available"), + "rejection should use the neutral message, got: {msg}" + ); +} + /// Build a `tools/call` for `name` with a single `text` argument. fn call(name: &'static str, text: &str) -> CallToolRequestParams { let args = serde_json::json!({ "text": text }); diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index 951d2e85..1014985d 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -228,9 +228,10 @@ async fn dispatch( } let snapshot = state.snapshot.load(); - // Scope the gateway to the tools this caller's key permits, so MCP tool - // access is governed by the same key object as LLM access. - let acl = aisix_mcp::ToolAcl::from_allowed(auth.key().allowed_tools.as_deref()); + // Scope the gateway to the tools this caller's key permits — resolved + // from the key together with the environment/team MCP access policies — + // so MCP tool access is governed by the same key object as LLM access. + let acl = aisix_mcp::ToolAcl::resolve(&snapshot, auth.key(), chrono::Utc::now()); let gateway = aisix_mcp::McpGateway::from_snapshot(&snapshot).with_tool_acl(acl); let service = aisix_mcp::streamable_http_service(gateway); let request = Request::from_parts(parts, Body::from(bytes)); @@ -599,6 +600,103 @@ mod tests { ); } + /// Read a JSON-RPC response body (the endpoint is configured for JSON + /// responses, not SSE). + async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body"); + serde_json::from_slice(&bytes).expect("JSON-RPC body") + } + + #[tokio::test] + async fn mcp_access_deny_mode_rejects_tool_calls_at_the_acl() { + // The key's legacy allowlist grants everything, but its mcp_access + // block says deny — the policy layer must win. The rejection is the + // ACL's neutral "not available" (reached before upstream routing), + // not the router's "unknown MCP server", which proves the endpoint + // resolves the ACL from the key + policies rather than allowed_tools + // alone. + let key_hash = ApiKey::hash_bearer(TOKEN); + let apikey: ApiKey = serde_json::from_value(serde_json::json!({ + "key_hash": key_hash, + "allowed_models": ["*"], + "allowed_tools": ["*"], + "mcp_access": { "mode": "deny" }, + })) + .expect("valid apikey"); + let snapshot = AisixSnapshot::new(); + snapshot + .apikeys + .insert(ResourceEntry::new("ak-1", apikey, 1)); + + let router = router_with(snapshot); + let resp = router + .oneshot(tools_call_request()) + .await + .expect("router responds"); + let body = body_json(resp).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("not available"), + "deny-mode key must be rejected by the ACL, got: {body}" + ); + } + + #[tokio::test] + async fn env_policy_deny_overlays_legacy_key_at_the_endpoint() { + // A legacy key (no mcp_access block) with a wildcard allowlist, plus + // an env policy that denies exactly one tool: the denied tool is + // rejected by the ACL while any other name still reaches routing + // (and fails as "unknown MCP server" — no upstreams are registered). + let key_hash = ApiKey::hash_bearer(TOKEN); + let apikey: ApiKey = serde_json::from_value(serde_json::json!({ + "key_hash": key_hash, + "allowed_models": ["*"], + "allowed_tools": ["*"], + })) + .expect("valid apikey"); + let policy: aisix_core::models::McpPolicy = serde_json::from_value(serde_json::json!({ + "scope": "env", + "mode": "none", + "deny": ["ghost__tool"], + })) + .expect("valid policy"); + let snapshot = AisixSnapshot::new(); + snapshot + .apikeys + .insert(ResourceEntry::new("ak-1", apikey, 1)); + snapshot + .mcp_policies + .insert(ResourceEntry::new("p-env", policy, 1)); + + let router = router_with(snapshot); + + let denied = router + .clone() + .oneshot(tools_call_request()) // calls ghost__tool + .await + .expect("router responds"); + let body = body_json(denied).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("not available"), + "env-policy deny must subtract from a legacy key, got: {body}" + ); + + let other = router + .oneshot(mcp_request( + "tools/call", + serde_json::json!({ "name": "other__tool", "arguments": {} }), + )) + .await + .expect("router responds"); + let body = body_json(other).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("unknown MCP server"), + "a non-denied tool must still pass the ACL for a wildcard legacy key, got: {body}" + ); + } + #[tokio::test] async fn rejects_request_without_api_key() { let router = router_with(snapshot_with_key()); diff --git a/schemas/resources/api_key.schema.json b/schemas/resources/api_key.schema.json index 7a74cc00..2ea11218 100644 --- a/schemas/resources/api_key.schema.json +++ b/schemas/resources/api_key.schema.json @@ -2,6 +2,64 @@ "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { + "McpAccess": { + "additionalProperties": false, + "description": "Policy-driven MCP access configuration on an API key. When present, this block supersedes the key's legacy `allowed_tools` list: the allow side of the key's effective grant is computed from the applicable [`McpPolicy`] rows and `mode`, and `allowed_tools` is not consulted.", + "properties": { + "allow": { + "description": "Namespaced `__` patterns intersected with the inherited grant when `mode` is `restrict`, using the same single-`*` glob matching as `allowed_tools`. Ignored for the other modes.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "description": "Namespaced `__` patterns subtracted from the key's effective grant, using the same single-`*` glob matching as `allow`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/McpAccessMode" + } + ], + "description": "How the key combines with the applicable policies: `inherit`, `restrict`, or `deny`." + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "McpAccessMode": { + "description": "Per-key MCP access block, carried on the API key resource. Selects how the key combines with the environment's and its team's [`McpPolicy`] rows.", + "oneOf": [ + { + "description": "The key uses the inherited grant unchanged: its team's active policy when one exists, otherwise the environment-default policy.", + "enum": [ + "inherit" + ], + "type": "string" + }, + { + "description": "The key uses the intersection of the inherited grant and its own `allow` patterns — a restriction can only narrow what the policies grant, never widen it.", + "enum": [ + "restrict" + ], + "type": "string" + }, + { + "description": "The key has no MCP tool access at all.", + "enum": [ + "deny" + ], + "type": "string" + } + ] + }, "RateLimit": { "additionalProperties": false, "properties": { @@ -117,6 +175,17 @@ "minLength": 1, "type": "string" }, + "mcp_access": { + "anyOf": [ + { + "$ref": "#/definitions/McpAccess" + }, + { + "type": "null" + } + ], + "description": "Policy-driven MCP access for this key. When present, it supersedes `allowed_tools`: the key's grant is computed from the environment's and its team's MCP access policies according to `mode` (`inherit`, `restrict`, or `deny`), and `allowed_tools` is not consulted. When omitted, the key keeps the explicit `allowed_tools` behavior — with policy `deny` patterns still subtracted, since deny applies to every key the policy covers." + }, "rate_limit": { "anyOf": [ { diff --git a/schemas/resources/mcp_policy.schema.json b/schemas/resources/mcp_policy.schema.json new file mode 100644 index 00000000..6c366593 --- /dev/null +++ b/schemas/resources/mcp_policy.schema.json @@ -0,0 +1,132 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "scope": { + "const": "team" + } + } + }, + "then": { + "properties": { + "scope_ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "scope_ref" + ] + } + } + ], + "definitions": { + "McpPolicyMode": { + "description": "What a [`McpPolicy`] grants before `deny` patterns are subtracted.", + "oneOf": [ + { + "description": "Grants no MCP tools.", + "enum": [ + "none" + ], + "type": "string" + }, + { + "description": "Grants exactly the tools matched by the `allow` patterns.", + "enum": [ + "selected" + ], + "type": "string" + }, + { + "description": "Grants every tool on every registered MCP server, including servers and tools added after the policy is created.", + "enum": [ + "all" + ], + "type": "string" + } + ] + }, + "McpPolicyScope": { + "description": "Which API keys a [`McpPolicy`] applies to.", + "oneOf": [ + { + "description": "The environment-wide default, applied to keys whose team has no active policy of its own.", + "enum": [ + "env" + ], + "type": "string" + }, + { + "description": "A team-level policy, applied to the keys that belong to the team named by `scope_ref`. It replaces the environment default for those keys.", + "enum": [ + "team" + ], + "type": "string" + } + ] + } + }, + "properties": { + "allow": { + "description": "Namespaced `__` patterns granted when `mode` is `selected`. Entries are matched as single-`*` globs, the same form as an API key's `allowed_tools`: `\"__*\"` grants every tool on one server and an entry without a `*` matches one tool exactly. Ignored for the other modes.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "description": "Namespaced `__` patterns subtracted from the effective grant of every key the policy applies to, using the same single-`*` glob matching as `allow`. Deny always wins: a tool matched here stays unavailable even when a team policy or a key's own configuration would otherwise grant it.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": true, + "description": "Whether the policy is applied. A disabled policy is kept but ignored, exactly like an expired one. Treated as `true` when omitted.", + "type": "boolean" + }, + "expires_at": { + "description": "RFC 3339 timestamp after which the policy stops applying — it then neither grants nor denies anything. When omitted or set to `null`, the policy never expires.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/McpPolicyMode" + } + ], + "description": "What the policy grants: `none`, `selected` (the `allow` patterns), or `all` current and future tools." + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/McpPolicyScope" + } + ], + "description": "Which API keys the policy applies to: the whole environment or one team." + }, + "scope_ref": { + "description": "Team identifier the policy targets. Required when `scope` is `team`; omitted for an environment-default policy.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "mode", + "scope" + ], + "title": "McpPolicy", + "type": "object" +} From d1425b485574244f40049bbfeeef5082d07c824e Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 27 Jul 2026 13:38:50 +0800 Subject: [PATCH 2/3] test(e2e): MCP access policy coverage over real SDK upstreams 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. --- tests/e2e/package.json | 1 + .../src/cases/mcp-access-policy-e2e.test.ts | 375 ++++++++++++++++++ tests/e2e/src/harness/index.ts | 1 + tests/e2e/src/harness/upstream-mcp.ts | 112 ++++++ 4 files changed, 489 insertions(+) create mode 100644 tests/e2e/src/cases/mcp-access-policy-e2e.test.ts create mode 100644 tests/e2e/src/harness/upstream-mcp.ts diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 16b72205..47c10cd2 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -18,6 +18,7 @@ "ws": "8.18.0" }, "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", "openai": "4.65.0", "undici": "6.19.8", "yaml": "2.5.1" diff --git a/tests/e2e/src/cases/mcp-access-policy-e2e.test.ts b/tests/e2e/src/cases/mcp-access-policy-e2e.test.ts new file mode 100644 index 00000000..7d5e08a5 --- /dev/null +++ b/tests/e2e/src/cases/mcp-access-policy-e2e.test.ts @@ -0,0 +1,375 @@ +import { createHash, randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + startMcpUpstream, + waitConfigPropagation, + type McpUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: layered MCP access policies against a real gateway + etcd + two real +// MCP upstreams (official TypeScript SDK servers). +// +// env default policy → selected [alpha__*], deny [beta__reverse] +// team T1 policy → selected [beta__*] (replaces the env grant) +// team T2 policy → all (replaces the env grant) +// per-key mcp_access → inherit / restrict / deny; absent = legacy +// +// Pinned contract, per key: +// - legacy keys keep their allowed_tools allow side (no policy widening), +// but policy deny patterns still subtract; +// - inherit keys take the team policy when one exists, else the env +// default; an env-level deny survives a team policy takeover; +// - restrict intersects (narrow-only); deny grants nothing; +// - tools/list hides what tools/call rejects (one ACL, two checkpoints); +// - policy edits and deletes propagate through the etcd watch path. + +const TEAM1 = "team-mcp-t1"; +const TEAM2 = "team-mcp-t2"; + +const ENV_POLICY_ID = "11111111-1111-1111-1111-11111111aaaa"; +const T1_POLICY_ID = "11111111-1111-1111-1111-11111111bbbb"; +const T2_POLICY_ID = "11111111-1111-1111-1111-11111111cccc"; + +const KEY_LEGACY_SCOPED = "sk-mcp-legacy-scoped"; +const KEY_LEGACY_WILD = "sk-mcp-legacy-wild"; +const KEY_INHERIT = "sk-mcp-inherit"; +const KEY_T1 = "sk-mcp-t1-inherit"; +const KEY_T2 = "sk-mcp-t2-inherit"; +const KEY_RESTRICT = "sk-mcp-t2-restrict"; +const KEY_DENY = "sk-mcp-deny"; + +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); + +interface RpcReply { + status: number; + json?: { + result?: { + tools?: Array<{ name: string }>; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + }; + error?: { code: number; message: string }; + }; +} + +describe("mcp access policy e2e: env default + team entitlement + key narrowing", () => { + let app: SpawnedApp | undefined; + let alpha: McpUpstream | undefined; + let beta: McpUpstream | undefined; + let etcdReachable = false; + let seed: SeedClient; + + const post = async (token: string, body: unknown): Promise => { + const res = await fetch(`${app!.proxyUrl}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json: RpcReply["json"]; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; + } + return { status: res.status, json }; + }; + + /** Spec-faithful per-operation handshake (the endpoint is stateless). */ + const initialize = async (token: string): Promise => { + const init = await post(token, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "mcp-acl-e2e", version: "0.1" }, + }, + }); + await post(token, { jsonrpc: "2.0", method: "notifications/initialized" }); + return init.status; + }; + + /** Sorted namespaced tool names visible to `token`, or an HTTP status. */ + const listToolNames = async ( + token: string, + ): Promise<{ status: number; names?: string[] }> => { + const status = await initialize(token); + if (status !== 200) return { status }; + const r = await post(token, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + const tools = r.json?.result?.tools; + if (r.status !== 200 || !tools) return { status: r.status }; + return { status: r.status, names: tools.map((t) => t.name).sort() }; + }; + + const callTool = async ( + token: string, + name: string, + text: string, + ): Promise<{ ok: boolean; text?: string; error?: string }> => { + await initialize(token); + const r = await post(token, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name, arguments: { text } }, + }); + if (r.json?.error) return { ok: false, error: r.json.error.message }; + const result = r.json?.result; + if (!result || result.isError) { + return { ok: false, error: JSON.stringify(r.json ?? r.status) }; + } + return { ok: true, text: result.content?.[0]?.text }; + }; + + const expectList = async (token: string, names: string[]) => { + const listed = await listToolNames(token); + expect(listed.status).toBe(200); + expect(listed.names).toEqual(names); + }; + + /** True once `token` lists exactly `names` — the propagation probe. */ + const listMatches = async ( + token: string, + names: string[], + ): Promise => { + const listed = await listToolNames(token); + return ( + listed.status === 200 && + JSON.stringify(listed.names) === JSON.stringify(names) + ); + }; + + const EXPECTED: Array<[string, string[]]> = [ + [KEY_LEGACY_SCOPED, ["alpha__echo", "alpha__reverse"]], + [KEY_LEGACY_WILD, ["alpha__echo", "alpha__reverse", "beta__echo"]], + [KEY_INHERIT, ["alpha__echo", "alpha__reverse"]], + [KEY_T1, ["beta__echo"]], + [KEY_T2, ["alpha__echo", "alpha__reverse", "beta__echo"]], + [KEY_RESTRICT, ["alpha__echo"]], + [KEY_DENY, []], + ]; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + alpha = await startMcpUpstream("alpha"); + beta = await startMcpUpstream("beta"); + app = await spawnApp(); + seed = new SeedClient(etcd, app.etcdPrefix); + + await seed.update("mcp_servers", randomUUID(), { + display_name: "alpha", + url: alpha.url, + enabled: true, + }); + await seed.update("mcp_servers", randomUUID(), { + display_name: "beta", + url: beta.url, + enabled: true, + }); + + 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 seed.update("mcp_policies", T2_POLICY_ID, { + scope: "team", + scope_ref: TEAM2, + mode: "all", + }); + + const keyDoc = ( + plaintext: string, + extra: Record, + ): Record => ({ + key_hash: sha256(plaintext), + allowed_models: [], + ...extra, + }); + await seed.createApiKey( + keyDoc(KEY_LEGACY_SCOPED, { allowed_tools: ["alpha__*"] }), + ); + await seed.createApiKey(keyDoc(KEY_LEGACY_WILD, { allowed_tools: ["*"] })); + await seed.createApiKey( + keyDoc(KEY_INHERIT, { mcp_access: { mode: "inherit" } }), + ); + await seed.createApiKey( + keyDoc(KEY_T1, { team_id: TEAM1, mcp_access: { mode: "inherit" } }), + ); + await seed.createApiKey( + keyDoc(KEY_T2, { team_id: TEAM2, mcp_access: { mode: "inherit" } }), + ); + await seed.createApiKey( + keyDoc(KEY_RESTRICT, { + team_id: TEAM2, + mcp_access: { + mode: "restrict", + allow: ["alpha__*"], + deny: ["alpha__reverse"], + }, + }), + ); + await seed.createApiKey( + keyDoc(KEY_DENY, { allowed_tools: ["*"], mcp_access: { mode: "deny" } }), + ); + + // Probe EVERY key to its expected steady state: keys are written at + // higher revisions than servers/policies, but each key's list also + // depends on its own row having landed — probing only one key would + // let another key's 401 flake a later assertion. + await waitConfigPropagation(async () => { + for (const [token, names] of EXPECTED) { + if (!(await listMatches(token, names))) return false; + } + return true; + }); + }, 60_000); + + afterAll(async () => { + await app?.exit(); + await alpha?.close(); + await beta?.close(); + }); + + test("legacy key keeps its allowed_tools scope", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + await expectList(KEY_LEGACY_SCOPED, ["alpha__echo", "alpha__reverse"]); + const ok = await callTool(KEY_LEGACY_SCOPED, "alpha__echo", "hi"); + expect(ok).toEqual({ ok: true, text: "alpha:hi" }); + const rejected = await callTool(KEY_LEGACY_SCOPED, "beta__echo", "hi"); + expect(rejected.ok).toBe(false); + expect(rejected.error).toContain("not available"); + }); + + test("policy deny subtracts from a legacy wildcard key without widening it", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // beta__reverse is hidden by the env deny even though allowed_tools=["*"]. + await expectList(KEY_LEGACY_WILD, [ + "alpha__echo", + "alpha__reverse", + "beta__echo", + ]); + const denied = await callTool(KEY_LEGACY_WILD, "beta__reverse", "hi"); + expect(denied.ok).toBe(false); + expect(denied.error).toContain("not available"); + const ok = await callTool(KEY_LEGACY_WILD, "beta__echo", "hi"); + expect(ok).toEqual({ ok: true, text: "beta:hi" }); + }); + + test("inherit takes the env default when the key has no team", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + await expectList(KEY_INHERIT, ["alpha__echo", "alpha__reverse"]); + const ok = await callTool(KEY_INHERIT, "alpha__reverse", "hi"); + expect(ok).toEqual({ ok: true, text: "ih" }); + const rejected = await callTool(KEY_INHERIT, "beta__echo", "hi"); + expect(rejected.ok).toBe(false); + expect(rejected.error).toContain("not available"); + }); + + test("a team policy replaces the env grant; the env deny survives", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // T1 grants beta__*; the env deny on beta__reverse still subtracts. + await expectList(KEY_T1, ["beta__echo"]); + const ok = await callTool(KEY_T1, "beta__echo", "hi"); + expect(ok).toEqual({ ok: true, text: "beta:hi" }); + const envDenied = await callTool(KEY_T1, "beta__reverse", "hi"); + expect(envDenied.ok).toBe(false); + expect(envDenied.error).toContain("not available"); + // The env grant (alpha__*) is replaced, not unioned. + const replaced = await callTool(KEY_T1, "alpha__echo", "hi"); + expect(replaced.ok).toBe(false); + expect(replaced.error).toContain("not available"); + }); + + test("a team `all` grant covers both servers, still minus the env deny", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + await expectList(KEY_T2, ["alpha__echo", "alpha__reverse", "beta__echo"]); + const denied = await callTool(KEY_T2, "beta__reverse", "hi"); + expect(denied.ok).toBe(false); + expect(denied.error).toContain("not available"); + }); + + test("restrict narrows the inherited grant and never widens it", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // Base is team T2 `all`; the key narrows to alpha__* minus alpha__reverse. + await expectList(KEY_RESTRICT, ["alpha__echo"]); + const ok = await callTool(KEY_RESTRICT, "alpha__echo", "hi"); + expect(ok).toEqual({ ok: true, text: "alpha:hi" }); + for (const tool of ["alpha__reverse", "beta__echo"]) { + const rejected = await callTool(KEY_RESTRICT, tool, "hi"); + expect(rejected.ok).toBe(false); + expect(rejected.error).toContain("not available"); + } + }); + + test("deny mode grants nothing, whatever allowed_tools says", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + await expectList(KEY_DENY, []); + const rejected = await callTool(KEY_DENY, "alpha__echo", "hi"); + expect(rejected.ok).toBe(false); + expect(rejected.error).toContain("not available"); + }); + + test("policy edits and deletes propagate through the watch path", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // Flip T1 to `none`: its member key loses everything. + await seed.update("mcp_policies", T1_POLICY_ID, { + scope: "team", + scope_ref: TEAM1, + mode: "none", + }); + await waitConfigPropagation(() => listMatches(KEY_T1, [])); + const rejected = await callTool(KEY_T1, "beta__echo", "hi"); + expect(rejected.ok).toBe(false); + expect(rejected.error).toContain("not available"); + + // Delete the env policy: its deny stops applying to the legacy + // wildcard key (all four tools return), and an inherit key with no + // policy left anywhere falls back to no access. + await seed.delete("mcp_policies", ENV_POLICY_ID); + await waitConfigPropagation(() => + listMatches(KEY_LEGACY_WILD, [ + "alpha__echo", + "alpha__reverse", + "beta__echo", + "beta__reverse", + ]), + ); + const restored = await callTool(KEY_LEGACY_WILD, "beta__reverse", "hi"); + expect(restored).toEqual({ ok: true, text: "ih" }); + await expectList(KEY_INHERIT, []); + }); +}); diff --git a/tests/e2e/src/harness/index.ts b/tests/e2e/src/harness/index.ts index fbba18b3..3cde1403 100644 --- a/tests/e2e/src/harness/index.ts +++ b/tests/e2e/src/harness/index.ts @@ -4,6 +4,7 @@ export { ProxyClient } from "./proxy.js"; export { EtcdClient } from "./etcd.js"; export { SeedClient } from "./seed.js"; export { startOpenAiUpstream, type OpenAiUpstream, type ReceivedRequest } from "./upstream-openai.js"; +export { startMcpUpstream, type McpUpstream } from "./upstream-mcp.js"; export { pickFreePort, pickFreePorts } from "./ports.js"; export { startMockSls, diff --git a/tests/e2e/src/harness/upstream-mcp.ts b/tests/e2e/src/harness/upstream-mcp.ts new file mode 100644 index 00000000..980aba20 --- /dev/null +++ b/tests/e2e/src/harness/upstream-mcp.ts @@ -0,0 +1,112 @@ +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from "node:http"; + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +export interface McpUpstream { + /** Streamable HTTP endpoint of this upstream (`http://127.0.0.1:/mcp`). */ + url: string; + close(): Promise; +} + +/** + * A real MCP upstream server built on the official TypeScript SDK, speaking + * the stateless Streamable HTTP transport with JSON responses — the exact + * interop partner of the gateway's per-operation ephemeral MCP client. + * + * Every upstream exposes the same two tools, labelled so tests can both + * observe routing and grant one tool while denying the other on one server: + * - `echo` → returns `