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
2 changes: 2 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ rand = "0.8"
regex = "1"
base64 = "0.22"
url = "2"
percent-encoding = "2"

# Crypto (M6 v3 CP register flow + ApiKey hash auth)
sha2 = "0.10"
Expand Down
3 changes: 3 additions & 0 deletions crates/aisix-admin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ description = "aisix: Admin API + Playground + OpenAPI"
[dependencies]
aisix-core = { path = "../aisix-core" }
aisix-etcd = { path = "../aisix-etcd" }
# Strict OpenAPI-spec validation (`validate_spec`) for `type: openapi`
# mcp_server writes.
aisix-mcp = { path = "../aisix-mcp" }
aisix-proxy = { path = "../aisix-proxy" }
tokio.workspace = true
axum.workspace = true
Expand Down
158 changes: 157 additions & 1 deletion crates/aisix-admin/src/mcp_servers_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use aisix_core::models::validate_mcp_server;
use aisix_core::resource::ResourceEntry;
use aisix_core::{McpAuthType, McpServer};
use aisix_core::{McpAuthType, McpServer, McpServerType};
use axum::extract::{Path, State};
use axum::Json;
use serde_json::Value;
Expand Down Expand Up @@ -130,6 +130,55 @@ fn decode(raw: &Value) -> Result<McpServer, AdminError> {
}
McpAuthType::Bearer | McpAuthType::ApiKey => {}
}
// Per-type coupling: an openapi-backed server must carry a spec that
// strictly generates at least one tool (clear errors for an unusable or
// colliding document); an MCP server must not carry openapi-only fields.
match server.server_type {
McpServerType::Openapi => {
let spec = server.spec.as_ref().ok_or_else(|| {
AdminError::BadRequest(
"spec (an OpenAPI 3.x document) is required when type is `openapi`".to_string(),
)
})?;
if !spec.is_object() {
return Err(AdminError::BadRequest(
"spec must be a JSON object (an OpenAPI 3.x document)".to_string(),
));
}
if let Some(version) = spec.get("swagger").and_then(Value::as_str) {
return Err(AdminError::BadRequest(format!(
"Swagger {version} documents are not supported — convert the spec to \
OpenAPI 3.x"
)));
}
aisix_mcp::validate_spec(spec)
.map_err(|e| AdminError::BadRequest(format!("invalid OpenAPI spec: {e}")))?;
if let Some(header) = server.api_key_header.as_deref() {
if server.auth_type != McpAuthType::ApiKey {
return Err(AdminError::BadRequest(
"api_key_header is only valid when auth_type is `api_key`".to_string(),
));
}
if http::HeaderName::from_bytes(header.as_bytes()).is_err() {
return Err(AdminError::BadRequest(format!(
"api_key_header `{header}` is not a valid HTTP header name"
)));
}
}
}
McpServerType::Mcp => {
if server.spec.is_some() {
return Err(AdminError::BadRequest(
"spec is only valid when type is `openapi`".to_string(),
));
}
if server.api_key_header.is_some() {
return Err(AdminError::BadRequest(
"api_key_header is only valid when type is `openapi`".to_string(),
));
}
}
}
Ok(server)
}

Expand Down Expand Up @@ -201,6 +250,113 @@ mod tests {
}
}

#[test]
fn decode_openapi_type_coupling() {
let minimal_spec = json!({
"openapi": "3.0.0",
"paths": { "/items": { "get": { "operationId": "listItems" } } }
});

// Well-formed openapi server passes.
let ok = decode(&json!({
"name": "erp",
"type": "openapi",
"url": "https://erp.internal/api",
"spec": minimal_spec,
"auth_type": "api_key",
"secret": "k",
"api_key_header": "X-ERP-Key"
}))
.expect("valid openapi server");
assert_eq!(ok.server_type, McpServerType::Openapi);

// Missing spec.
let err = decode(&json!({
"name": "erp",
"type": "openapi",
"url": "https://erp.internal/api"
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("spec")),
"{err:?}"
);

// Swagger 2.0 gets a targeted conversion hint.
let err = decode(&json!({
"name": "erp",
"type": "openapi",
"url": "https://erp.internal/api",
"spec": { "swagger": "2.0", "paths": { "/a": { "get": {} } } }
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("OpenAPI 3")),
"{err:?}"
);

// Colliding operationIds are a clear write-time error.
let err = decode(&json!({
"name": "erp",
"type": "openapi",
"url": "https://erp.internal/api",
"spec": { "paths": {
"/a": { "get": { "operationId": "foo/list" } },
"/b": { "get": { "operationId": "foo.list" } }
} }
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("duplicate tool names")),
"{err:?}"
);

// api_key_header demands api_key auth and a valid header name.
let err = decode(&json!({
"name": "erp",
"type": "openapi",
"url": "https://erp.internal/api",
"spec": minimal_spec,
"api_key_header": "X-Key"
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("auth_type")),
"{err:?}"
);
let err = decode(&json!({
"name": "erp",
"type": "openapi",
"url": "https://erp.internal/api",
"spec": minimal_spec,
"auth_type": "api_key",
"secret": "k",
"api_key_header": "bad header\nname"
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("header name")),
"{err:?}"
);

// Openapi-only fields are rejected on a plain MCP server.
for (field, value) in [
("spec", minimal_spec.clone()),
("api_key_header", json!("X-Key")),
] {
let err = decode(&json!({
"name": "gh",
"url": "https://x/mcp",
field: value
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("openapi")),
"{field}: {err:?}"
);
}
}

#[test]
fn decode_accepts_api_key_and_oauth2_servers() {
let api_key = decode(&json!({
Expand Down
8 changes: 4 additions & 4 deletions crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ pub use models::{
ApiKey, AppliedGuardrail, CachePolicy, CooldownConfig, ExporterKind, Guardrail,
GuardrailExecution, GuardrailHookPoint, GuardrailKind, GuardrailMetricsSink,
GuardrailMonitorHit, KeywordConfig, KeywordPattern, McpAuthType, McpRateLimit, McpServer,
McpTransport, Model, ObservabilityExporter, ParamConstraints, PolicyScope, PolicyWindow,
ProviderKey, RateLimit, RateLimitPolicy, RequestOverrides, ResponseOverrides, Routing,
RoutingStrategy, RoutingTarget, SchemaError, StreamDoneMarker, TelemetryKind, TelemetryTags,
WhenAllUnavailablePolicy, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
McpServerType, McpTransport, Model, ObservabilityExporter, ParamConstraints, PolicyScope,
PolicyWindow, ProviderKey, RateLimit, RateLimitPolicy, RequestOverrides, ResponseOverrides,
Routing, RoutingStrategy, RoutingTarget, SchemaError, StreamDoneMarker, TelemetryKind,
TelemetryTags, WhenAllUnavailablePolicy, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
pub use resource::{Resource, ResourceEntry};
pub use snapshot::{ResourceTable, SnapshotHandle};
Expand Down
114 changes: 103 additions & 11 deletions crates/aisix-core/src/models/mcp_server.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
//! `McpServer` entity — a registered upstream MCP server.
//! `McpServer` entity — a registered MCP tool source.
//!
//! Registers an upstream Model Context Protocol (MCP) server so the gateway can
//! front it: its tools are aggregated into the gateway's own MCP endpoint under
//! the namespace `<name>__<tool>`, and tool calls are routed back to it.
//! The upstream credential is held by the gateway and is never exposed to the
//! calling client.
//! Registers either an upstream Model Context Protocol (MCP) server the
//! gateway fronts (`type: mcp`), or a REST API described by an OpenAPI
//! document whose operations the gateway itself exposes as tools
//! (`type: openapi`). Either way the tools are aggregated into the gateway's
//! own MCP endpoint under the namespace `<name>__<tool>`, and tool calls are
//! routed back to the source. The upstream credential is held by the gateway
//! and is never exposed to the calling client.
//!
//! etcd path: `{prefix}/mcp_servers/{uuid}`. Secondary index on `name`.

use serde::{Deserialize, Serialize};

use crate::resource::Resource;

#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
// `Eq` is deliberately absent: `spec` holds a `serde_json::Value`, which is
// only `PartialEq` (JSON numbers are floats).
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct McpServer {
/// Operator-facing label, unique within the gateway. It is used as the
Expand All @@ -26,11 +30,32 @@ pub struct McpServer {
#[schemars(length(min = 1))]
pub name: String,

/// The upstream server's MCP endpoint URL, reached over the Streamable HTTP
/// transport, such as `https://api.example.com/mcp`.
/// What backs this server: a real upstream MCP server (`mcp`, the
/// default), or a plain REST API described by an OpenAPI document
/// (`openapi`) whose operations the gateway itself exposes as MCP tools.
#[serde(rename = "type", default)]
pub server_type: McpServerType,

/// For `type: mcp`, the upstream server's MCP endpoint URL, reached over
/// the Streamable HTTP transport, such as `https://api.example.com/mcp`.
/// For `type: openapi`, the REST API's base URL that generated tool calls
/// are issued against, such as `https://erp.internal/api/v1`.
#[schemars(length(min = 1))]
pub url: String,

/// The OpenAPI 3.x document (as a JSON object) whose operations become
/// this server's tools. Required when `type` is `openapi`; ignored
/// otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<serde_json::Value>,

/// Header name the API key is sent under when `type` is `openapi` and
/// `auth_type` is `api_key`. Defaults to `x-api-key` when unset. Ignored
/// for `type: mcp`, whose API-key header is fixed.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(min = 1))]
pub api_key_header: Option<String>,

/// Transport used to reach the upstream server. Streamable HTTP is the only
/// supported transport.
#[serde(default)]
Expand All @@ -45,8 +70,9 @@ pub struct McpServer {
/// 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`.
/// `api_key` (sent as `x-api-key: <secret>`, or under `api_key_header`
/// for `type: openapi`), 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>,

Expand Down Expand Up @@ -96,6 +122,20 @@ fn default_enabled() -> bool {
true
}

/// What backs a registered MCP server entry.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum McpServerType {
/// A real upstream MCP server the gateway connects to.
#[default]
Mcp,
/// A REST API described by an OpenAPI document; the gateway generates the
/// tools itself and issues plain HTTP requests against `url`.
Openapi,
}

/// Transport used to reach an upstream MCP server.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
Expand Down Expand Up @@ -291,7 +331,10 @@ mod tests {
fn round_trip_omits_default_optionals() {
let original = McpServer {
name: "github".into(),
server_type: McpServerType::Mcp,
url: "https://x/mcp".into(),
spec: None,
api_key_header: None,
transport: McpTransport::StreamableHttp,
auth_type: McpAuthType::None,
secret: None,
Expand All @@ -303,7 +346,56 @@ mod tests {
runtime_id: String::new(),
};
let s = serde_json::to_string(&original).unwrap();
// Unset openapi-mode fields are omitted from the wire shape entirely.
assert!(!s.contains("spec"), "got: {s}");
assert!(!s.contains("api_key_header"), "got: {s}");
let back: McpServer = serde_json::from_str(&s).unwrap();
assert_eq!(original, back);
}

// ---- `type: openapi` ----

#[test]
fn defaults_to_mcp_type() {
let s: McpServer =
serde_json::from_str(r#"{"name":"github","url":"https://x/mcp"}"#).unwrap();
assert_eq!(s.server_type, McpServerType::Mcp);
assert!(s.spec.is_none());
assert!(s.api_key_header.is_none());
}

#[test]
fn deserialises_openapi_server_with_spec() {
let s: McpServer = serde_json::from_str(
r#"{"name":"erp","type":"openapi","url":"https://erp.internal/api",
"spec":{"openapi":"3.0.0","paths":{}},
"auth_type":"api_key","secret":"k","api_key_header":"X-ERP-Key"}"#,
)
.unwrap();
assert_eq!(s.server_type, McpServerType::Openapi);
assert_eq!(
s.spec.as_ref().and_then(|v| v.get("openapi")),
Some(&serde_json::Value::String("3.0.0".into()))
);
assert_eq!(s.api_key_header.as_deref(), Some("X-ERP-Key"));
}

#[test]
fn openapi_type_round_trips() {
let original: McpServer = serde_json::from_str(
r#"{"name":"erp","type":"openapi","url":"https://erp.internal/api","spec":{"openapi":"3.1.0","paths":{"/a":{"get":{"operationId":"x"}}}}}"#,
)
.unwrap();
let s = serde_json::to_string(&original).unwrap();
assert!(s.contains(r#""type":"openapi""#), "got: {s}");
let back: McpServer = serde_json::from_str(&s).unwrap();
assert_eq!(original, back);
}

#[test]
fn rejects_unknown_server_type() {
assert!(
serde_json::from_str::<McpServer>(r#"{"name":"x","url":"u","type":"grpc"}"#).is_err()
);
}
}
Loading
Loading