Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 88 additions & 6 deletions crates/aisix-admin/src/mcp_servers_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,34 @@ fn decode(raw: &Value) -> Result<McpServer, AdminError> {
"display_name must not contain the reserved separator `{TOOL_NAMESPACE_SEPARATOR}`"
)));
}
if matches!(server.auth_type, McpAuthType::Bearer)
&& server.secret.as_deref().unwrap_or_default().is_empty()
{
return Err(AdminError::BadRequest(
"secret is required and must be non-empty when auth_type is `bearer`".to_string(),
));
// Per-auth_type credential coupling. The JSON schema stays flat and
// permissive on this (see the note on the McpServer struct); the write
// path is where an incomplete credential set is rejected outright.
let has_secret = !server.secret.as_deref().unwrap_or_default().is_empty();
match server.auth_type {
McpAuthType::None => {}
McpAuthType::Bearer if !has_secret => {
return Err(AdminError::BadRequest(
"secret is required and must be non-empty when auth_type is `bearer`".to_string(),
));
}
McpAuthType::ApiKey if !has_secret => {
return Err(AdminError::BadRequest(
"secret is required and must be non-empty when auth_type is `api_key`".to_string(),
));
}
McpAuthType::OAuth2 => {
let has_client_id = !server.client_id.as_deref().unwrap_or_default().is_empty();
let has_token_url = !server.token_url.as_deref().unwrap_or_default().is_empty();
if !has_secret || !has_client_id || !has_token_url {
return Err(AdminError::BadRequest(
"client_id, token_url, and secret (the OAuth client secret) are required \
and must be non-empty when auth_type is `oauth2`"
.to_string(),
));
}
}
McpAuthType::Bearer | McpAuthType::ApiKey => {}
}
Ok(server)
}
Expand Down Expand Up @@ -148,6 +170,66 @@ mod tests {
assert!(matches!(err, AdminError::BadRequest(_)));
}

#[test]
fn decode_rejects_api_key_without_secret() {
let err = decode(&json!({
"display_name": "gh",
"url": "https://x/mcp",
"auth_type": "api_key"
}))
.expect_err("api_key auth without a secret must be rejected");
assert!(matches!(err, AdminError::BadRequest(_)));
}

#[test]
fn decode_rejects_incomplete_oauth2() {
// Each of client_id / token_url / secret is individually required.
for missing in ["client_id", "token_url", "secret"] {
let mut v = json!({
"display_name": "gh",
"url": "https://x/mcp",
"auth_type": "oauth2",
"client_id": "cid",
"token_url": "https://auth.example.com/oauth/token",
"secret": "cs"
});
v.as_object_mut().unwrap().remove(missing);
let err = decode(&v).unwrap_err();
assert!(
matches!(err, AdminError::BadRequest(_)),
"oauth2 without `{missing}` must be a BadRequest"
);
}
}

#[test]
fn decode_accepts_api_key_and_oauth2_servers() {
let api_key = decode(&json!({
"display_name": "gh",
"url": "https://x/mcp",
"auth_type": "api_key",
"secret": "k-1"
}))
.expect("valid api_key server should decode");
assert_eq!(api_key.secret.as_deref(), Some("k-1"));

let oauth2 = decode(&json!({
"display_name": "gh2",
"url": "https://x/mcp",
"auth_type": "oauth2",
"client_id": "cid",
"token_url": "https://auth.example.com/oauth/token",
"secret": "cs",
"scopes": ["read"]
}))
.expect("valid oauth2 server should decode");
assert_eq!(oauth2.client_id.as_deref(), Some("cid"));
assert_eq!(
oauth2.token_url.as_deref(),
Some("https://auth.example.com/oauth/token")
);
}

#[test]
fn decode_accepts_valid_server() {
let server = decode(&json!({
Expand Down
4 changes: 2 additions & 2 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1417,7 +1417,7 @@ const OPENAPI_JSON_BASE: &str = r##"{
}
},
"400": {
"description": "Schema validation failed, the JSON body is malformed, `display_name` contains the reserved `__` separator, or bearer auth is missing `secret`",
"description": "Schema validation failed, the JSON body is malformed, `display_name` contains the reserved `__` separator, or the credentials required by `auth_type` are missing (`secret` for `bearer`/`api_key`; `client_id`, `token_url`, and `secret` for `oauth2`)",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -1575,7 +1575,7 @@ const OPENAPI_JSON_BASE: &str = r##"{
}
},
"400": {
"description": "Schema validation failed, the JSON body is malformed, `display_name` contains the reserved `__` separator, or bearer auth is missing `secret`",
"description": "Schema validation failed, the JSON body is malformed, `display_name` contains the reserved `__` separator, or the credentials required by `auth_type` are missing (`secret` for `bearer`/`api_key`; `client_id`, `token_url`, and `secret` for `oauth2`)",
"content": {
"application/json": {
"schema": {
Expand Down
91 changes: 87 additions & 4 deletions crates/aisix-core/src/models/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,39 @@ pub struct McpServer {
#[serde(default)]
pub auth_type: McpAuthType,

/// Authentication credential for the upstream server. Required when
/// `auth_type` is `bearer`, where it is sent as `Authorization: Bearer
/// <secret>` on every upstream request. Leave unset when `auth_type` is
/// `none`.
/// Authentication credential for the upstream server. Its meaning follows
/// `auth_type`: the bearer token when `auth_type` is `bearer` (sent as
/// `Authorization: Bearer <secret>`), the API key when `auth_type` is
/// `api_key` (sent as `x-api-key: <secret>`), or the OAuth client secret
/// when `auth_type` is `oauth2`. Leave unset when `auth_type` is `none`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,

// Cross-field coupling (`oauth2` requires `client_id` + `secret` +
// `token_url`; `bearer`/`api_key` require `secret`) is deliberately NOT
// expressed in this flat schema — that would force restructuring the
// resource into a oneOf. The control plane enforces the coupling strictly
// at write time, this gateway's own Admin API re-checks it on write, and
// the runtime degrades gracefully when a snapshot-loaded server is
// mis-configured: its credential exchange fails, its tools become
// unavailable, and the failure is logged like any other upstream failure.
/// OAuth client identifier used for the OAuth 2.0 client credentials
/// grant. Required when `auth_type` is `oauth2`; ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,

/// OAuth token endpoint URL where the gateway exchanges the client
/// credentials for an access token, such as
/// `https://auth.example.com/oauth/token`. Required when `auth_type` is
/// `oauth2`; ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,

/// OAuth scopes to request. Joined with spaces into the `scope` parameter
/// of the token request. Only used when `auth_type` is `oauth2`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,

/// Maximum time, in milliseconds, to wait for a single upstream operation
/// (establishing the session, listing tools, or calling a tool). Must be at
/// least `1` when set. When omitted, the gateway applies a built-in default.
Expand Down Expand Up @@ -90,6 +116,16 @@ pub enum McpAuthType {
/// Bearer token authentication. The token is supplied in `secret` and sent
/// as `Authorization: Bearer <secret>`.
Bearer,
/// API key authentication. The key is supplied in `secret` and sent as an
/// `x-api-key: <secret>` header on every upstream request.
ApiKey,
/// OAuth 2.0 client credentials grant. The gateway exchanges `client_id`,
/// the client secret in `secret`, and the optional `scopes` at `token_url`
/// for an access token, and sends it as `Authorization: Bearer
/// <access_token>` on every upstream request. Access tokens are cached
/// until shortly before their reported expiry.
#[serde(rename = "oauth2")]
OAuth2,
}

impl Resource for McpServer {
Expand Down Expand Up @@ -122,6 +158,9 @@ mod tests {
assert_eq!(s.transport, McpTransport::StreamableHttp);
assert_eq!(s.auth_type, McpAuthType::None);
assert!(s.secret.is_none());
assert!(s.client_id.is_none());
assert!(s.token_url.is_none());
assert!(s.scopes.is_none());
assert!(s.timeout_ms.is_none());
assert!(s.enabled);
}
Expand All @@ -138,6 +177,47 @@ mod tests {
assert!(!s.enabled);
}

#[test]
fn deserialises_with_api_key_auth() {
let s: McpServer = serde_json::from_str(
r#"{"display_name":"gh","url":"https://x/mcp","auth_type":"api_key","secret":"k-1"}"#,
)
.unwrap();
assert_eq!(s.auth_type, McpAuthType::ApiKey);
assert_eq!(s.secret.as_deref(), Some("k-1"));
}

#[test]
fn deserialises_with_oauth2_auth() {
let s: McpServer = serde_json::from_str(
r#"{"display_name":"gh","url":"https://x/mcp","auth_type":"oauth2","secret":"cs-1","client_id":"cid","token_url":"https://auth/x/token","scopes":["read","write"]}"#,
)
.unwrap();
assert_eq!(s.auth_type, McpAuthType::OAuth2);
assert_eq!(s.secret.as_deref(), Some("cs-1"));
assert_eq!(s.client_id.as_deref(), Some("cid"));
assert_eq!(s.token_url.as_deref(), Some("https://auth/x/token"));
assert_eq!(
s.scopes.as_deref(),
Some(&["read".to_string(), "write".to_string()][..])
);
}

#[test]
fn oauth2_round_trips_and_omits_unset_optionals() {
let original: McpServer = serde_json::from_str(
r#"{"display_name":"gh","url":"https://x/mcp","auth_type":"oauth2","secret":"cs","client_id":"cid","token_url":"https://auth/token"}"#,
)
.unwrap();
let s = serde_json::to_string(&original).unwrap();
// The oauth2 tag serialises as `oauth2` (not a snake_cased `o_auth2`)
// and unset optionals (`scopes` here) are omitted entirely.
assert!(s.contains(r#""auth_type":"oauth2""#), "got: {s}");
assert!(!s.contains("scopes"), "unset scopes must be omitted: {s}");
let back: McpServer = serde_json::from_str(&s).unwrap();
assert_eq!(original, back);
}

#[test]
fn rejects_unknown_fields() {
let r: Result<McpServer, _> =
Expand Down Expand Up @@ -175,6 +255,9 @@ mod tests {
transport: McpTransport::StreamableHttp,
auth_type: McpAuthType::None,
secret: None,
client_id: None,
token_url: None,
scopes: None,
timeout_ms: None,
enabled: true,
runtime_id: String::new(),
Expand Down
80 changes: 75 additions & 5 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,18 +183,26 @@ pub fn provider_key_root_schema() -> Value {

/// Canonical JSON Schema for the `mcp_server` resource, derived from the
/// [`McpServer`](crate::models::McpServer) struct. Uses the nullable `Option`
/// representation (`true`) so the optional `secret` / `timeout_ms` fields accept
/// an explicit `null` as well as being absent, matching the resource's wire
/// contract. The `transport` / `auth_type` closed sets come from the
/// representation (`true`) so the optional fields (`secret`, `client_id`,
/// `token_url`, `scopes`, `timeout_ms`) accept an explicit `null` as well as
/// being absent, matching the resource's wire contract. The `transport` /
/// `auth_type` closed sets come from the
/// [`McpTransport`](crate::models::McpTransport) /
/// [`McpAuthType`](crate::models::McpAuthType) enums.
/// [`McpAuthType`](crate::models::McpAuthType) enums. The per-`auth_type`
/// credential coupling is intentionally not encoded here (see the note on the
/// struct); the schema stays permissive and write paths enforce it.
pub fn mcp_server_root_schema() -> Value {
let mut schema = struct_root_schema::<crate::models::McpServer>(true);
if let Some(Value::Object(defs)) = schema.get_mut("definitions") {
title_single_value_enum_variants(
defs,
"McpAuthType",
&[("none", "No authentication"), ("bearer", "Bearer token")],
&[
("none", "No authentication"),
("bearer", "Bearer token"),
("api_key", "API key"),
("oauth2", "OAuth 2.0 client credentials"),
],
);
title_single_value_enum_variants(
defs,
Expand Down Expand Up @@ -2163,6 +2171,68 @@ mod tests {
validate_mcp_server(&v).unwrap();
}

#[test]
fn mcp_server_accepts_api_key_auth() {
let v = json!({
"display_name": "github",
"url": "https://api.example.com/mcp",
"auth_type": "api_key",
"secret": "k-123"
});
validate_mcp_server(&v).unwrap();
}

#[test]
fn mcp_server_accepts_oauth2_auth_with_client_fields() {
let v = json!({
"display_name": "github",
"url": "https://api.example.com/mcp",
"auth_type": "oauth2",
"secret": "client-secret",
"client_id": "cid",
"token_url": "https://auth.example.com/oauth/token",
"scopes": ["read", "write"]
});
validate_mcp_server(&v).unwrap();
}

#[test]
fn mcp_server_rejects_unknown_auth_type_and_bad_scopes_shape() {
// The `auth_type` set is closed: near-misses like `oauth` must fail.
let v = json!({
"display_name": "x",
"url": "https://x/mcp",
"auth_type": "oauth"
});
assert!(validate_mcp_server(&v).is_err());

// `scopes` is an array of strings, not a single space-joined string.
let v = json!({
"display_name": "x",
"url": "https://x/mcp",
"auth_type": "oauth2",
"secret": "s",
"client_id": "cid",
"token_url": "https://auth/token",
"scopes": "read write"
});
assert!(validate_mcp_server(&v).is_err());
}

#[test]
fn mcp_server_schema_stays_permissive_on_credential_coupling() {
// The per-`auth_type` credential coupling (oauth2 ⇒ client_id +
// secret + token_url) is enforced by write paths, not this schema —
// an incomplete oauth2 row must still validate so the snapshot loader
// keeps it (the runtime degrades that server gracefully instead).
let v = json!({
"display_name": "x",
"url": "https://x/mcp",
"auth_type": "oauth2"
});
validate_mcp_server(&v).unwrap();
}

#[test]
fn mcp_server_rejects_zero_timeout_ms() {
// A zero deadline times out every upstream op instantly and silently
Expand Down
16 changes: 16 additions & 0 deletions crates/aisix-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
# Upstream OAuth 2.0 client-credentials token minting (`crate::oauth`) plus
# header types for the `api_key` transport headers.
http.workspace = true
reqwest.workspace = true
# rmcp 1.8.0 builds its transport on the reqwest 0.13 line (the workspace is
# on 0.12) and does not re-export it. This renamed dep exists ONLY to name
# `StreamableHttpError<reqwest::Error>` when downcasting a connect failure to
# detect an upstream 401 — it resolves to the same crate version rmcp links,
# so the downcast sees the identical type. Kept `default-features = false`:
# only the `Error` type is used, never a client. The 401-invalidation
# integration test fails loudly if these ever stop unifying.
rmcp-reqwest = { package = "reqwest", version = "0.13", default-features = false }
# Token-cache keying: the client secret is folded into the cache key as a
# SHA-256 digest so a rotated secret never reuses the previous secret's token.
sha2.workspace = true
hex.workspace = true

# Official MCP Rust SDK. Pinned exactly: rmcp is <16 months old and still
# ships breaking changes on a roughly-monthly cadence, so we hold a fixed
Expand Down
Loading
Loading