Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/aisix-core/src/bin/dump-schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ fn main() {
"oidc_provider",
schema::oidc_provider_root_schema(),
);
dump_value(
&out_dir,
"mcp_auth_settings",
schema::mcp_auth_settings_root_schema(),
);

dump::<EnsembleConfig>(&out_dir, "ensemble");
dump::<RateLimit>(&out_dir, "rate_limit");
Expand Down
7 changes: 7 additions & 0 deletions crates/aisix-core/src/filesource/desugar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ pub(crate) enum IdentityField {
/// `name`, with `display_name` accepted as the alternative spelling
/// (mcp_servers, a2a_agents — mirroring their schemas).
NameOrDisplayName,
/// A fixed constant — the kind is a per-file singleton
/// (mcp_auth_settings) whose entries carry no identity field of
/// their own. Every entry shares the constant, so a second entry
/// trips the pass-1 duplicate check.
Fixed(&'static str),
}

impl IdentityField {
Expand All @@ -70,6 +75,7 @@ impl IdentityField {
Self::DisplayName => "display_name",
Self::Name => "name",
Self::NameOrDisplayName => "name (or display_name)",
Self::Fixed(_) => "the singleton identity",
}
}

Expand All @@ -86,6 +92,7 @@ impl IdentityField {
Self::DisplayName => field("display_name"),
Self::Name => field("name"),
Self::NameOrDisplayName => field("name").or_else(|| field("display_name")),
Self::Fixed(v) => Some(v.to_string()),
}
}
}
Expand Down
42 changes: 38 additions & 4 deletions crates/aisix-core/src/filesource/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ use yaml_rust2::{Yaml, YamlLoader};

use crate::models::{
validate_a2a_agent, validate_apikey, validate_cache_policy, validate_guardrail,
validate_mcp_server, validate_model, validate_observability_exporter, validate_oidc_provider,
validate_provider_key, validate_rate_limit_policy, A2aAgent, ApiKey, CachePolicy, Guardrail,
validate_mcp_auth_settings, validate_mcp_server, validate_model,
validate_observability_exporter, validate_oidc_provider, validate_provider_key,
validate_rate_limit_policy, A2aAgent, ApiKey, CachePolicy, Guardrail, McpAuthSettings,
McpServer, Model, ObservabilityExporter, OidcProvider, ProviderKey, RateLimitPolicy,
SchemaError,
};
Expand Down Expand Up @@ -130,8 +131,8 @@ pub(crate) fn url_has_credentials(url: &str) -> bool {
false
}

/// Fixed processing order for the ten resource collections.
const KINDS: [(&str, IdentityField); 10] = [
/// Fixed processing order for the eleven resource collections.
const KINDS: [(&str, IdentityField); 11] = [
("provider_keys", IdentityField::DisplayName),
("models", IdentityField::DisplayName),
("api_keys", IdentityField::DisplayName),
Expand All @@ -142,6 +143,12 @@ const KINDS: [(&str, IdentityField); 10] = [
("observability_exporters", IdentityField::Name),
("rate_limit_policies", IdentityField::Name),
("oidc_providers", IdentityField::Name),
// Singleton: the fixed identity makes a second entry a duplicate
// at pass 1, enforcing at-most-one row per file.
(
"mcp_auth_settings",
IdentityField::Fixed("mcp_auth_settings"),
),
];

/// Load `path` into a fresh [`AisixSnapshot`], resolving `${VAR}`
Expand Down Expand Up @@ -346,6 +353,7 @@ pub fn load_from_str(
let mut observability_exporters: Vec<(String, String, ObservabilityExporter)> = Vec::new();
let mut rate_limit_policies: Vec<(String, String, RateLimitPolicy)> = Vec::new();
let mut oidc_providers: Vec<(String, String, OidcProvider)> = Vec::new();
let mut mcp_auth_settings: Vec<(String, String, McpAuthSettings)> = Vec::new();

for mut entry in prepared {
let id = derive_id(entry.kind, &entry.identity);
Expand Down Expand Up @@ -436,6 +444,12 @@ pub fn load_from_str(
oidc_providers.push((id, scope, t));
}
}
"mcp_auth_settings" => {
if let Some(t) = finish(&scope, &entry.doc, validate_mcp_auth_settings, &mut errors)
{
mcp_auth_settings.push((id, scope, t));
}
}
other => unreachable!("kind {other} is not in KINDS"),
}
}
Expand Down Expand Up @@ -595,6 +609,21 @@ pub fn load_from_str(
}
}

// Same rule for the MCP OAuth resource URL: it is published verbatim
// on the unauthenticated protected-resource-metadata endpoint, so an
// embedded credential would be world-readable.
for (_, scope, settings) in &mcp_auth_settings {
if url_has_credentials(&settings.resource_url) {
errors.push(LoadError {
scope: scope.clone(),
message: "mcp_auth_settings resource_url must not embed credentials (user \
info or a token query parameter) — protected resource metadata \
is public"
.into(),
});
}
}

// An explicit `provider_key_id` must also resolve: in file mode every
// provider-key id is derived from its name, so any other value is
// guaranteed dangling and would only surface per-request.
Expand Down Expand Up @@ -671,6 +700,11 @@ pub fn load_from_str(
.oidc_providers
.insert(ResourceEntry::new(id, v, revision));
}
for (id, _, v) in mcp_auth_settings {
snapshot
.mcp_auth_settings
.insert(ResourceEntry::new(id, v, revision));
}
Ok(snapshot)
}

Expand Down
49 changes: 49 additions & 0 deletions crates/aisix-core/src/filesource/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,55 @@ oidc_providers:
);
}

#[test]
fn mcp_auth_settings_singleton_loads() {
let contents = r#"
_format_version: "1"
mcp_auth_settings:
- resource_url: https://gw.example.com/mcp
"#;
let snapshot = load(contents, &env_of(&[])).expect("loads");
assert_eq!(snapshot.mcp_auth_settings.len(), 1);
let entry = snapshot.mcp_auth_settings.entries().pop().unwrap();
assert_eq!(entry.value.resource_url, "https://gw.example.com/mcp");
}

#[test]
fn mcp_auth_settings_resource_url_with_credentials_is_a_load_error() {
// The resource URL is served verbatim on the unauthenticated PRM
// endpoint, so embedded credentials must fail the load — same rule
// as OIDC issuer/jwks_uri.
let contents = r#"
_format_version: "1"
mcp_auth_settings:
- resource_url: https://user:s3cret@gw.example.com/mcp
"#;
let errs = errors_of(load(contents, &env_of(&[])));
assert!(
errs.iter()
.any(|e| e.contains("must not embed credentials")),
"{errs:?}"
);
}

#[test]
fn duplicate_mcp_auth_settings_is_a_load_error() {
// The kind is a per-environment singleton: its fixed identity makes
// any second entry a pass-1 duplicate (AISIX-Cloud#1143).
let contents = r#"
_format_version: "1"
mcp_auth_settings:
- resource_url: https://gw.example.com/mcp
- resource_url: https://other.example.com/mcp
"#;
let errs = errors_of(load(contents, &env_of(&[])));
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(
errs[0].contains("duplicate mcp_auth_settings entry"),
"{errs:?}"
);
}

#[test]
fn oidc_url_with_embedded_credentials_is_a_load_error() {
let contents = r#"
Expand Down
87 changes: 87 additions & 0 deletions crates/aisix-core/src/models/mcp_auth_settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! `McpAuthSettings` entity — the environment's inbound MCP OAuth
//! discovery identity, stored in etcd under `mcp_auth_settings/<uuid>`
//! (the control plane keys the singleton row by the environment id).
//!
//! Carrying a valid row activates the `/mcp` OAuth 2.1 resource-server
//! discovery surface (AISIX-Cloud#1143) when at least one enabled
//! [`OidcProvider`](super::oidc_provider::OidcProvider) also exists:
//! the RFC 9728 Protected Resource Metadata document is served under
//! `/.well-known/oauth-protected-resource`, and `/mcp` auth failures
//! carry a `WWW-Authenticate` challenge pointing at it. Without the
//! row the surface stays dormant and behavior is unchanged.
//!
//! At most one row exists per environment; the declarative resources
//! file rejects a document carrying more than one entry at load.

use serde::{Deserialize, Serialize};

use crate::resource::Resource;

#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct McpAuthSettings {
/// Canonical URI of this environment's `/mcp` endpoint, e.g.
/// `https://gw.example.com/mcp`. Published verbatim as the PRM
/// document's `resource` (never derived from the request Host
/// header) and the value the trust providers' `audiences` must
/// include for OAuth-for-MCP tokens to validate. The URL path must
/// be exactly `/mcp` — the gateway's fixed MCP route.
#[schemars(length(min = 1))]
pub resource_url: String,

/// etcd-key uuid. Filled by the loader and never included in the
/// JSON payload.
#[serde(skip)]
pub(crate) runtime_id: String,
}

impl Resource for McpAuthSettings {
fn id(&self) -> &str {
&self.runtime_id
}

/// Fixed identity: the row is a per-environment singleton, so the
/// by-name index key is a constant rather than a user-chosen label.
fn name(&self) -> &str {
"mcp_auth_settings"
}

fn kind() -> &'static str {
"mcp_auth_settings"
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deserialises_minimal_settings() {
let s: McpAuthSettings =
serde_json::from_str(r#"{"resource_url": "https://gw.example.com/mcp"}"#).unwrap();
assert_eq!(s.resource_url, "https://gw.example.com/mcp");
}

#[test]
fn rejects_unknown_fields() {
let r: Result<McpAuthSettings, _> =
serde_json::from_str(r#"{"resource_url": "https://gw.example.com/mcp", "extra": 1}"#);
assert!(r.is_err());
}

#[test]
fn rejects_missing_resource_url() {
let r: Result<McpAuthSettings, _> = serde_json::from_str(r#"{}"#);
assert!(r.is_err());
}

#[test]
fn resource_trait_uses_fixed_identity() {
assert_eq!(McpAuthSettings::kind(), "mcp_auth_settings");
let mut s: McpAuthSettings =
serde_json::from_str(r#"{"resource_url": "https://gw.example.com/mcp"}"#).unwrap();
s.runtime_id = "env-uuid-1".into();
assert_eq!(s.id(), "env-uuid-1");
assert_eq!(s.name(), "mcp_auth_settings");
}
}
8 changes: 5 additions & 3 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod cache_policy;
pub mod embedding;
pub mod ensemble;
pub mod guardrail;
pub mod mcp_auth_settings;
pub mod mcp_policy;
pub mod mcp_server;
pub mod model;
Expand All @@ -47,6 +48,7 @@ pub use guardrail::{
GuardrailScopeType, KeywordConfig, KeywordPattern, LakeraConfig, OpenaiModerationConfig,
PiiConfig, PiiCustomPattern, PiiDetectorConfig, PresidioConfig, PresidioEntityConfig,
};
pub use mcp_auth_settings::McpAuthSettings;
pub use mcp_policy::{McpAccess, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope};
pub use mcp_server::{McpAuthType, McpServer, McpServerType, McpTransport};
pub use model::{
Expand All @@ -66,9 +68,9 @@ 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_policy, validate_mcp_server, validate_model,
validate_observability_exporter, validate_oidc_provider, validate_provider_key,
validate_rate_limit_policy, SchemaError,
validate_guardrail_attachment, validate_mcp_auth_settings, validate_mcp_policy,
validate_mcp_server, validate_model, validate_observability_exporter, validate_oidc_provider,
validate_provider_key, validate_rate_limit_policy, SchemaError,
};
pub use semantic::{
Aggregation, DistanceMetric, EmbeddingFailureMode, OnEmbeddingFailure, Semantic, SemanticMatch,
Expand Down
18 changes: 18 additions & 0 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct Schemas {
pub mcp_policy: Validator,
pub a2a_agent: Validator,
pub oidc_provider: Validator,
pub mcp_auth_settings: Validator,
}

pub static SCHEMAS: Lazy<Arc<Schemas>> = Lazy::new(|| Arc::new(Schemas::compile()));
Expand Down Expand Up @@ -77,6 +78,9 @@ impl Schemas {
oidc_provider: jsonschema::options()
.build(&oidc_provider_root_schema())
.expect("oidc_provider schema is well-formed"),
mcp_auth_settings: jsonschema::options()
.build(&mcp_auth_settings_root_schema())
.expect("mcp_auth_settings schema is well-formed"),
}
}
}
Expand Down Expand Up @@ -155,6 +159,10 @@ pub fn validate_oidc_provider(value: &Value) -> Result<(), SchemaError> {
validate(&SCHEMAS.oidc_provider, value)
}

pub fn validate_mcp_auth_settings(value: &Value) -> Result<(), SchemaError> {
validate(&SCHEMAS.mcp_auth_settings, value)
}

/// Build a resource's canonical JSON Schema from its struct via `schemars`,
/// the single source of field shapes and per-field constraints.
///
Expand Down Expand Up @@ -415,6 +423,16 @@ pub fn oidc_provider_root_schema() -> Value {
schema
}

/// Canonical JSON Schema for the `mcp_auth_settings` resource, derived
/// from the [`McpAuthSettings`](crate::models::McpAuthSettings) struct.
/// One required field (`resource_url`); the singleton-per-environment
/// invariant is enforced by the writers (cp-api keys the row by the
/// environment id; the resources file rejects duplicates at load), not
/// by the document schema.
pub fn mcp_auth_settings_root_schema() -> Value {
struct_root_schema::<crate::models::McpAuthSettings>(false)
}

/// 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` accepts an explicit `null` as
Expand Down
8 changes: 8 additions & 0 deletions crates/aisix-core/src/models/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_auth_settings::McpAuthSettings;
use super::mcp_policy::McpPolicy;
use super::mcp_server::McpServer;
use super::model::Model;
Expand Down Expand Up @@ -56,6 +57,12 @@ pub struct AisixSnapshot {
/// request to the API key whose `jwt_subject` equals the token's
/// identity claim.
pub oidc_providers: ResourceTable<OidcProvider>,
/// The environment's inbound MCP OAuth discovery identity:
/// `/aisix/<env>/mcp_auth_settings/<env-uuid>` — a singleton row
/// (cp-api keys it by the environment id). Together with at least
/// one enabled `oidc_providers` row it activates the `/mcp`
/// RFC 9728 discovery surface (AISIX-Cloud#1143).
pub mcp_auth_settings: ResourceTable<McpAuthSettings>,
}

impl AisixSnapshot {
Expand All @@ -78,6 +85,7 @@ impl AisixSnapshot {
+ self.mcp_policies.len()
+ self.a2a_agents.len()
+ self.oidc_providers.len()
+ self.mcp_auth_settings.len()
}
}

Expand Down
Loading