From e89ed65587fd70bc85bbfc5078c3d39fd87cecfb Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 29 Jul 2026 22:13:20 +0800 Subject: [PATCH] feat(mcp): expose REST APIs as MCP tools from an OpenAPI spec (type: openapi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registered mcp_server can now be backed by a plain REST API instead of a real MCP upstream: 'type: openapi' plus an OpenAPI 3.x document in 'spec' makes the gateway generate one tool per operation and execute tools/call as HTTP requests against 'url', with the gateway-held credential injected (bearer / api_key with an optional api_key_header override / oauth2 client credentials) and never exposed to the calling agent. Generation follows LiteLLM's openapi_to_mcp_generator for familiarity: sanitized operationId tool names (fallback method_path, _2/_3 suffixes on collisions at runtime), path/query parameters as schema properties, a JSON request body as a single 'body' property. Beyond the baseline: bounded local $ref resolution keeps referenced schemas' shape, non-2xx responses and argument mistakes surface as tool-level isError results, operations whose body has no application/json variant are skipped instead of emitting a broken tool, and header/cookie parameters are never exposed to the agent. The Admin API validates strictly at write time (missing/invalid spec, Swagger 2.0, zero generatable operations, duplicate sanitized tool names, api_key_header coupling) via the new aisix_mcp::validate_spec; snapshot loading stays permissive and degrades a broken row like an unreachable upstream. Existing governance — approval flow, per-tool ACLs, per-server rate limits, guardrails — applies unchanged since the openapi bridge sits behind the same McpBridge surface. Ref api7/AISIX-Cloud#1077 --- Cargo.lock | 2 + Cargo.toml | 1 + crates/aisix-admin/Cargo.toml | 3 + .../aisix-admin/src/mcp_servers_handlers.rs | 158 ++- crates/aisix-core/src/lib.rs | 8 +- crates/aisix-core/src/models/mcp_server.rs | 114 +- crates/aisix-core/src/models/mod.rs | 2 +- crates/aisix-core/src/models/schema.rs | 8 + crates/aisix-mcp/Cargo.toml | 2 + crates/aisix-mcp/src/bridge.rs | 2 +- crates/aisix-mcp/src/gateway.rs | 28 +- crates/aisix-mcp/src/lib.rs | 2 + crates/aisix-mcp/src/openapi.rs | 1169 +++++++++++++++++ .../aisix-mcp/tests/openapi_tool_roundtrip.rs | 336 +++++ schemas/resources/mcp_server.schema.json | 45 +- tests/e2e/src/cases/mcp-openapi-e2e.test.ts | 307 +++++ tests/e2e/src/harness/index.ts | 1 + tests/e2e/src/harness/upstream-rest.ts | 93 ++ 18 files changed, 2252 insertions(+), 29 deletions(-) create mode 100644 crates/aisix-mcp/src/openapi.rs create mode 100644 crates/aisix-mcp/tests/openapi_tool_roundtrip.rs create mode 100644 tests/e2e/src/cases/mcp-openapi-e2e.test.ts create mode 100644 tests/e2e/src/harness/upstream-rest.ts diff --git a/Cargo.lock b/Cargo.lock index f0d950b8..ed6284d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,7 @@ dependencies = [ "aisix-core", "aisix-etcd", "aisix-gateway", + "aisix-mcp", "aisix-obs", "aisix-provider-openai", "aisix-proxy", @@ -189,6 +190,7 @@ dependencies = [ "futures", "hex", "http 1.4.0", + "percent-encoding", "reqwest 0.12.28", "reqwest 0.13.4", "rmcp", diff --git a/Cargo.toml b/Cargo.toml index a1fe948d..f529c186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/aisix-admin/Cargo.toml b/crates/aisix-admin/Cargo.toml index a68c4a38..1613cc2f 100644 --- a/crates/aisix-admin/Cargo.toml +++ b/crates/aisix-admin/Cargo.toml @@ -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 diff --git a/crates/aisix-admin/src/mcp_servers_handlers.rs b/crates/aisix-admin/src/mcp_servers_handlers.rs index 6ee4b6d3..ff20f647 100644 --- a/crates/aisix-admin/src/mcp_servers_handlers.rs +++ b/crates/aisix-admin/src/mcp_servers_handlers.rs @@ -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; @@ -130,6 +130,55 @@ fn decode(raw: &Value) -> Result { } 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) } @@ -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!({ diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index 6a837469..f9588293 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -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}; diff --git a/crates/aisix-core/src/models/mcp_server.rs b/crates/aisix-core/src/models/mcp_server.rs index 72a2d96b..423c9eb5 100644 --- a/crates/aisix-core/src/models/mcp_server.rs +++ b/crates/aisix-core/src/models/mcp_server.rs @@ -1,10 +1,12 @@ -//! `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 `__`, 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 `__`, 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`. @@ -12,7 +14,9 @@ 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 @@ -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, + + /// 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, + /// Transport used to reach the upstream server. Streamable HTTP is the only /// supported transport. #[serde(default)] @@ -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 `), the API key when `auth_type` is - /// `api_key` (sent as `x-api-key: `), or the OAuth client secret - /// when `auth_type` is `oauth2`. Leave unset when `auth_type` is `none`. + /// `api_key` (sent as `x-api-key: `, 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, @@ -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, @@ -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, @@ -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::(r#"{"name":"x","url":"u","type":"grpc"}"#).is_err() + ); + } } diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index 5024ef48..03a21d2b 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -48,7 +48,7 @@ pub use guardrail::{ PiiConfig, PiiCustomPattern, PiiDetectorConfig, PresidioConfig, PresidioEntityConfig, }; pub use mcp_policy::{McpAccess, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope}; -pub use mcp_server::{McpAuthType, McpServer, McpTransport}; +pub use mcp_server::{McpAuthType, McpServer, McpServerType, McpTransport}; pub use model::{ Adapter, BackgroundModelCheck, CooldownConfig, Model, DEFAULT_COOLDOWN_TRIGGER_STATUSES, }; diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index a80b77ca..bc3fa9b7 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -313,6 +313,14 @@ pub fn mcp_server_root_schema() -> Value { "McpTransport", &[("streamable_http", "Streamable HTTP")], ); + title_single_value_enum_variants( + defs, + "McpServerType", + &[ + ("mcp", "Upstream MCP server"), + ("openapi", "REST API described by an OpenAPI document"), + ], + ); } schema } diff --git a/crates/aisix-mcp/Cargo.toml b/crates/aisix-mcp/Cargo.toml index c0d116f3..e8db7d86 100644 --- a/crates/aisix-mcp/Cargo.toml +++ b/crates/aisix-mcp/Cargo.toml @@ -37,6 +37,8 @@ rmcp-reqwest = { package = "reqwest", version = "0.13", default-features = false # SHA-256 digest so a rotated secret never reuses the previous secret's token. sha2.workspace = true hex.workspace = true +# Path-parameter encoding for OpenAPI-backed tool calls (`crate::openapi`). +percent-encoding.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 diff --git a/crates/aisix-mcp/src/bridge.rs b/crates/aisix-mcp/src/bridge.rs index 01dbc4c2..a970c321 100644 --- a/crates/aisix-mcp/src/bridge.rs +++ b/crates/aisix-mcp/src/bridge.rs @@ -284,7 +284,7 @@ impl RmcpBridge { /// Bound an upstream-derived error message for logging: control characters /// (log-injection vectors) are stripped and the text is truncated, since a /// bare non-success response embeds the upstream's body verbatim. -fn sanitize_error_message(message: &str) -> String { +pub(crate) fn sanitize_error_message(message: &str) -> String { const MAX_LEN: usize = 256; let cleaned: String = message .chars() diff --git a/crates/aisix-mcp/src/gateway.rs b/crates/aisix-mcp/src/gateway.rs index 65377acd..ff1f5b3a 100644 --- a/crates/aisix-mcp/src/gateway.rs +++ b/crates/aisix-mcp/src/gateway.rs @@ -35,10 +35,13 @@ use rmcp::transport::streamable_http_server::session::local::LocalSessionManager use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; use rmcp::{RoleServer, ServerHandler}; -use aisix_core::models::{ApiKey, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope}; +use aisix_core::models::{ + ApiKey, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope, McpServerType, +}; use aisix_core::{AisixSnapshot, ResourceEntry}; use crate::bridge::{upstream_from_mcp_server, EphemeralBridge, McpBridge}; +use crate::openapi::OpenApiBridge; /// Separator between an upstream server's registered name and a tool name in /// the aggregated namespace, e.g. `github__create_issue`. Server names must @@ -274,11 +277,12 @@ impl McpGateway { } /// Build a gateway whose upstreams are the **enabled** `mcp_servers` in the - /// snapshot, each reached through an [`EphemeralBridge`] (connect per - /// request). Disabled servers are skipped. Registration order follows the - /// snapshot's iteration order; duplicate names are deduped (first - /// wins) by [`McpGateway::new`], though the Admin API already enforces - /// uniqueness. + /// snapshot: a `type: mcp` server is reached through an [`EphemeralBridge`] + /// (connect per request), a `type: openapi` server through an + /// [`OpenApiBridge`] that serves tools generated from its spec. Disabled + /// servers are skipped. Registration order follows the snapshot's + /// iteration order; duplicate names are deduped (first wins) by + /// [`McpGateway::new`], though the Admin API already enforces uniqueness. pub fn from_snapshot(snapshot: &AisixSnapshot) -> Self { let upstreams = snapshot .mcp_servers @@ -286,9 +290,15 @@ impl McpGateway { .into_iter() .filter(|entry| entry.value.enabled) .map(|entry| { - let upstream = upstream_from_mcp_server(&entry.value); - let bridge: Arc = Arc::new(EphemeralBridge::new(upstream)); - (entry.value.name.clone(), bridge) + let name = entry.value.name.clone(); + let bridge: Arc = match entry.value.server_type { + McpServerType::Mcp => { + let upstream = upstream_from_mcp_server(&entry.value); + Arc::new(EphemeralBridge::new(upstream)) + } + McpServerType::Openapi => Arc::new(OpenApiBridge::new(entry)), + }; + (name, bridge) }); McpGateway::new(upstreams) } diff --git a/crates/aisix-mcp/src/lib.rs b/crates/aisix-mcp/src/lib.rs index 3a346048..09f15952 100644 --- a/crates/aisix-mcp/src/lib.rs +++ b/crates/aisix-mcp/src/lib.rs @@ -17,6 +17,7 @@ pub mod bridge; pub mod error; pub mod gateway; mod oauth; +pub mod openapi; pub use bridge::{ upstream_from_mcp_server, EphemeralBridge, McpAuth, McpBridge, McpTool, McpToolResult, @@ -24,3 +25,4 @@ pub use bridge::{ }; pub use error::McpError; pub use gateway::{streamable_http_service, McpGateway, ToolAcl, TOOL_NAMESPACE_SEPARATOR}; +pub use openapi::{validate_spec, OpenApiBridge}; diff --git a/crates/aisix-mcp/src/openapi.rs b/crates/aisix-mcp/src/openapi.rs new file mode 100644 index 00000000..845a3348 --- /dev/null +++ b/crates/aisix-mcp/src/openapi.rs @@ -0,0 +1,1169 @@ +//! OpenAPI-backed MCP bridge (`type: openapi`). +//! +//! Instead of tunnelling to a real upstream MCP server, this bridge generates +//! the tool surface itself from a registered OpenAPI 3.x document and executes +//! `tools/call` as plain HTTP requests against the API's base URL. Each +//! `paths` operation becomes one tool; the gateway-held credential is injected +//! on every outbound request and is never visible to the calling agent. +//! +//! The generation rules follow LiteLLM's `openapi_to_mcp_generator` so tool +//! names and argument shapes stay familiar across gateways: the tool name is +//! the sanitized `operationId` (fallback `_`), path/query +//! parameters become top-level schema properties, and a JSON request body +//! becomes a single `body` property. Two deliberate improvements over the +//! baseline: local `$ref`s are resolved (bounded) so referenced schemas keep +//! their shape, and a non-2xx response is flagged `is_error` so the agent can +//! react to a failed call. +//! +//! The spec is read from the resource snapshot (shared, never re-fetched at +//! runtime): the control plane validates and materializes it at write time, so +//! the tool set only changes when the resource does. + +use std::collections::HashSet; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use aisix_core::{McpAuthType, McpServer, ResourceEntry}; +use async_trait::async_trait; +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use serde_json::{json, Map, Value}; + +use crate::bridge::{McpBridge, McpTool, McpToolResult, OAuthClientConfig}; +use crate::error::McpError; + +/// Header the API key is sent under for `auth_type: api_key` when the +/// resource sets no `api_key_header`. +pub const DEFAULT_API_KEY_HEADER: &str = "x-api-key"; + +/// Tool names must survive every major LLM provider's `^[a-zA-Z0-9_-]+$` +/// name check; 128 is the most restrictive cap (mirrors LiteLLM). +const TOOL_NAME_MAX_LEN: usize = 128; + +/// HTTP methods that map to tools, in generation order. +const METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"]; + +/// `$ref` inlining bounds, per operation: a cyclic or pathologically nested +/// schema degrades to `{}` (schema "anything") instead of recursing forever +/// or exploding the inlined size. +const MAX_REF_DEPTH: usize = 16; +const MAX_REF_EXPANSIONS: usize = 256; + +/// Everything except RFC 3986 unreserved characters is percent-encoded when a +/// path parameter value is substituted into the URL template. +const PATH_SEGMENT_ENCODE: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'/') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}') + .add(b'\\') + .add(b'^') + .add(b'|') + .add(b'&') + .add(b'+') + .add(b',') + .add(b':') + .add(b';') + .add(b'=') + .add(b'@') + .add(b'[') + .add(b']') + .add(b'!') + .add(b'$') + .add(b'\'') + .add(b'(') + .add(b')') + .add(b'*'); + +/// Shared HTTP client for generated tool calls: the process-wide upstream +/// connection settings, no redirect following (a redirect could re-send the +/// gateway-held credential to a host the operator never configured). +fn http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + aisix_gateway::client_builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build openapi tool HTTP client") + }) +} + +/// One OpenAPI operation, resolved into a callable tool. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct GeneratedTool { + pub name: String, + pub description: String, + pub input_schema: Value, + /// Lowercase HTTP method. + pub method: String, + /// The path template as written in the spec, e.g. `/items/{id}`. + pub path: String, + pub path_params: Vec, + pub query_params: Vec, + pub has_body: bool, +} + +/// [`McpBridge`] over an OpenAPI-backed `mcp_server` resource. Holds the +/// snapshot entry (`Arc`, shared with the snapshot) so the spec is never +/// deep-cloned per request. +pub struct OpenApiBridge { + entry: Arc>, + timeout: Duration, +} + +impl OpenApiBridge { + pub fn new(entry: Arc>) -> Self { + let timeout = entry + .value + .timeout_ms + .map(Duration::from_millis) + .unwrap_or(crate::bridge::DEFAULT_UPSTREAM_TIMEOUT); + Self { entry, timeout } + } + + fn server(&self) -> &McpServer { + &self.entry.value + } + + fn tools(&self) -> Result, McpError> { + let spec = self.server().spec.as_ref().ok_or_else(|| { + McpError::Request("openapi server has no spec configured".to_string()) + })?; + generate_tools(spec) + } + + /// Inject the gateway-held credential for this server. For `oauth2` this + /// mints (or reuses) an access token via the shared token cache. + async fn apply_auth( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let server = self.server(); + Ok(match server.auth_type { + McpAuthType::None => request, + McpAuthType::Bearer => request.bearer_auth(server.secret.as_deref().unwrap_or("")), + McpAuthType::ApiKey => { + let header = server + .api_key_header + .as_deref() + .filter(|h| !h.is_empty()) + .unwrap_or(DEFAULT_API_KEY_HEADER); + request.header(header, server.secret.as_deref().unwrap_or("")) + } + McpAuthType::OAuth2 => { + let token = crate::oauth::get_or_fetch(&self.oauth_config()).await?; + request.bearer_auth(token) + } + }) + } + + fn oauth_config(&self) -> OAuthClientConfig { + let server = self.server(); + OAuthClientConfig { + client_id: server.client_id.clone().unwrap_or_default(), + client_secret: server.secret.clone().unwrap_or_default(), + token_url: server.token_url.clone().unwrap_or_default(), + scopes: server.scopes.clone().unwrap_or_default(), + } + } + + async fn execute( + &self, + tool: &GeneratedTool, + arguments: &Value, + ) -> Result { + let args = match arguments { + Value::Object(map) => map.clone(), + Value::Null => Map::new(), + _ => { + return Err(McpError::Request( + "tool arguments must be a JSON object or null".to_string(), + )) + } + }; + + // An argument-shaped failure is a tool-level error result, not a + // protocol error: the agent sees the message and can correct the + // call, mirroring how a non-2xx response is surfaced. + let url = match build_url(&self.server().url, tool, &args) { + Ok(url) => url, + Err(message) => return Ok(tool_error(message)), + }; + let method = reqwest::Method::from_bytes(tool.method.to_uppercase().as_bytes()) + .map_err(|_| McpError::Request(format!("unsupported HTTP method {}", tool.method)))?; + + let mut request = http_client().request(method, url); + request = self.apply_auth(request).await?; + + let query = build_query_pairs(tool, &args); + if !query.is_empty() { + request = request.query(&query); + } + + if tool.has_body { + if let Some(body) = coerce_body(args.get("body")) { + request = request.json(&body); + } + } + + // The error text (which may embed the operator-configured base URL) + // is logged server-side by the gateway and never returned to the + // agent; sanitizing bounds it and strips log-injection vectors. + let response = request.send().await.map_err(|e| { + McpError::Request(format!( + "HTTP request failed: {}", + crate::bridge::sanitize_error_message(&e.to_string()) + )) + })?; + + let status = response.status(); + // Mirrors the connect-time posture in `bridge.rs`: a 401 against a + // minted token means it was revoked early — drop the cache entry so + // the next call re-mints instead of replaying it. + if status == reqwest::StatusCode::UNAUTHORIZED + && self.server().auth_type == McpAuthType::OAuth2 + { + crate::oauth::invalidate(&self.oauth_config()); + } + let body = response.text().await.map_err(|e| { + McpError::Request(format!( + "failed to read response: {}", + crate::bridge::sanitize_error_message(&e.to_string()) + )) + })?; + + if status.is_success() { + Ok(McpToolResult { + content: json!([{ "type": "text", "text": body }]), + structured_content: None, + is_error: false, + }) + } else { + Ok(tool_error(format!("HTTP {}: {}", status.as_u16(), body))) + } + } +} + +/// A tool-level error result (`isError: true` with a text message) — the +/// agent-visible failure shape for bad arguments and non-2xx responses. +fn tool_error(text: String) -> McpToolResult { + McpToolResult { + content: json!([{ "type": "text", "text": text }]), + structured_content: None, + is_error: true, + } +} + +#[async_trait] +impl McpBridge for OpenApiBridge { + async fn list_tools(&self) -> Result, McpError> { + Ok(self + .tools()? + .into_iter() + .map(|t| McpTool { + name: t.name, + description: Some(t.description), + input_schema: t.input_schema, + }) + .collect()) + } + + async fn call_tool(&self, name: &str, arguments: Value) -> Result { + let tools = self.tools()?; + let tool = tools + .iter() + .find(|t| t.name == name) + .ok_or_else(|| McpError::Request(format!("unknown tool '{name}'")))?; + tokio::time::timeout(self.timeout, self.execute(tool, &arguments)) + .await + .map_err(|_| McpError::Request("tool call timed out".to_string()))? + } +} + +/// Map an `operationId` (or fallback) to a provider-safe tool name: +/// lowercase, any character outside `[a-zA-Z0-9_-]` replaced with `_`, +/// capped at [`TOOL_NAME_MAX_LEN`]. Mirrors LiteLLM's sanitizer. +fn sanitize_tool_name(raw: &str) -> String { + raw.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .take(TOOL_NAME_MAX_LEN) + .collect() +} + +/// Strictly validate an OpenAPI document for `type: openapi` registration, +/// returning the generated tool names on success. +/// +/// This is the write-path (Admin API / control plane) contract: a document +/// with no usable `paths`, zero generatable operations, or post-sanitization +/// tool-name collisions is rejected with a message naming the problem — +/// unlike [`generate_tools`], whose runtime posture is to degrade. +pub fn validate_spec(spec: &Value) -> Result, McpError> { + let generation = generate(spec)?; + if !generation.duplicates.is_empty() { + return Err(McpError::Request(format!( + "duplicate tool names after operationId sanitization: {} — make the \ + operationIds distinct under lowercase [a-z0-9_-]", + generation.duplicates.join(", ") + ))); + } + if generation.tools.is_empty() { + let mut message = + "spec has no operations that can become tools (methods get/post/put/delete/patch)" + .to_string(); + if !generation.skipped.is_empty() { + message.push_str(&format!( + "; skipped operations without an application/json request body: {}", + generation.skipped.join(", ") + )); + } + return Err(McpError::Request(message)); + } + Ok(generation.tools.into_iter().map(|t| t.name).collect()) +} + +/// Generate the tool set from an OpenAPI 3.x document. +/// +/// Anomalies inside a single operation (a request body without an +/// `application/json` variant, an unresolvable parameter ref) skip or degrade +/// that operation only; the error path is reserved for a document that has no +/// usable `paths` object at all. Name collisions after sanitization are +/// disambiguated with `_2` / `_3` … suffixes — the write path rejects them +/// via [`validate_spec`], so this only defends rows written past it. +pub(crate) fn generate_tools(spec: &Value) -> Result, McpError> { + Ok(generate(spec)?.tools) +} + +/// Outcome of walking a spec: the tools plus the anomalies the strict write +/// path reports (and the runtime path merely logs). +struct Generation { + tools: Vec, + /// Base names that collided after sanitization (each listed once). + duplicates: Vec, + /// ` ` of operations skipped for an unsupported body. + skipped: Vec, +} + +fn generate(spec: &Value) -> Result { + let paths = spec + .get("paths") + .and_then(Value::as_object) + .ok_or_else(|| McpError::Request("openapi spec has no `paths` object".to_string()))?; + + let components = spec.get("components").cloned().unwrap_or(Value::Null); + let mut used_names: HashSet = HashSet::new(); + let mut tools = Vec::new(); + let mut duplicates: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + + for (path, path_item) in paths { + let Some(path_item) = path_item.as_object() else { + continue; + }; + for method in METHODS { + let Some(operation) = path_item.get(method).and_then(Value::as_object) else { + continue; + }; + + let mut resolver = RefResolver::new(spec); + let params = merged_parameters(path_item, operation, &components, &mut resolver); + + // A request body we cannot express (no JSON variant) skips the + // operation: a tool missing its body argument would mislead the + // agent into calls that cannot succeed. + let request_body = operation + .get("requestBody") + .map(|rb| resolver.resolve(rb, 0)); + let body_schema = match &request_body { + Some(rb) => match json_body_schema(rb, &mut resolver) { + BodyOutcome::Schema(schema) => Some(schema), + BodyOutcome::None => None, + BodyOutcome::Unsupported => { + tracing::debug!( + path = %path, + method = %method, + "skipping operation: request body has no application/json content" + ); + skipped.push(format!("{} {}", method.to_uppercase(), path)); + continue; + } + }, + None => None, + }; + + let raw_name = operation + .get("operationId") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{method}_{path}")); + let base_name = sanitize_tool_name(&raw_name); + + // Disambiguate names that collide after sanitization so every + // tool stays reachable (`foo/list` and `foo.list` both map to + // `foo_list`). + let mut name = base_name.clone(); + let mut n = 1; + while !used_names.insert(name.clone()) { + if n == 1 && !duplicates.contains(&base_name) { + duplicates.push(base_name.clone()); + } + n += 1; + let suffix = format!("_{n}"); + let keep = TOOL_NAME_MAX_LEN - suffix.len(); + name = base_name.chars().take(keep).collect::() + &suffix; + } + + let description = operation + .get("summary") + .or_else(|| operation.get("description")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{} {}", method.to_uppercase(), path)); + + let mut properties = Map::new(); + let mut required = Vec::new(); + let mut path_params = Vec::new(); + let mut query_params = Vec::new(); + + for param in ¶ms { + let Some(param_name) = param.get("name").and_then(Value::as_str) else { + continue; + }; + let location = param.get("in").and_then(Value::as_str).unwrap_or(""); + match location { + "path" => path_params.push(param_name.to_string()), + "query" => query_params.push(param_name.to_string()), + // header/cookie parameters are not exposed to the agent: + // upstream headers are the gateway's to set, not the + // caller's. + _ => continue, + } + + let mut schema = match param.get("schema") { + Some(s) => resolver.resolve(s, 0), + None => Value::Null, + }; + if !schema.is_object() || schema.as_object().is_some_and(Map::is_empty) { + schema = json!({ "type": "string" }); + } + if let Some(desc) = param.get("description").and_then(Value::as_str) { + schema + .as_object_mut() + .expect("schema coerced to object above") + .insert("description".to_string(), json!(desc)); + } + properties.insert(param_name.to_string(), schema); + + if param.get("required").and_then(Value::as_bool) == Some(true) { + required.push(param_name.to_string()); + } + } + + let has_body = if let Some(mut schema) = body_schema { + // A non-object or degraded-to-`{}` schema still gets an + // object hint, mirroring the string default on parameters. + if !schema.is_object() || schema.as_object().is_some_and(Map::is_empty) { + schema = json!({ "type": "object" }); + } + if let Some(desc) = request_body + .as_ref() + .and_then(|rb| rb.get("description")) + .and_then(Value::as_str) + { + schema + .as_object_mut() + .expect("schema coerced to object above") + .insert("description".to_string(), json!(desc)); + } + properties.insert("body".to_string(), schema); + if request_body + .as_ref() + .and_then(|rb| rb.get("required")) + .and_then(Value::as_bool) + == Some(true) + { + required.push("body".to_string()); + } + true + } else { + false + }; + + tools.push(GeneratedTool { + name, + description, + input_schema: json!({ + "type": "object", + "properties": properties, + "required": required, + }), + method: method.to_string(), + path: path.clone(), + path_params, + query_params, + has_body, + }); + } + } + + Ok(Generation { + tools, + duplicates, + skipped, + }) +} + +/// How an operation's `requestBody` maps onto the tool schema. +enum BodyOutcome { + /// `application/json` variant found; its (resolved) schema. + Schema(Value), + /// The body object carries no `content` at all — treat as body-less. + None, + /// A body exists but has no JSON variant (multipart upload, form data…). + Unsupported, +} + +fn json_body_schema(request_body: &Value, resolver: &mut RefResolver<'_>) -> BodyOutcome { + let Some(content) = request_body.get("content").and_then(Value::as_object) else { + return BodyOutcome::None; + }; + if content.is_empty() { + return BodyOutcome::None; + } + // Accept `application/json` plus parameterized variants like + // `application/json; charset=utf-8` or `application/problem+json`. + let json_variant = content.iter().find(|(mime, _)| { + let mime = mime.split(';').next().unwrap_or("").trim(); + mime.eq_ignore_ascii_case("application/json") + || (mime.starts_with("application/") && mime.ends_with("+json")) + }); + match json_variant { + Some((_, media)) => { + let schema = media + .get("schema") + .map(|s| resolver.resolve(s, 0)) + .unwrap_or_else(|| json!({ "type": "object" })); + BodyOutcome::Schema(schema) + } + None => BodyOutcome::Unsupported, + } +} + +/// Merge path-level and operation-level parameters (operation wins on the +/// same `(name, in)` pair), resolving `#/components/parameters/*` refs and +/// dropping unresolvable entries. +fn merged_parameters( + path_item: &Map, + operation: &Map, + components: &Value, + resolver: &mut RefResolver<'_>, +) -> Vec { + let resolve_list = |raw: Option<&Value>, resolver: &mut RefResolver<'_>| -> Vec { + raw.and_then(Value::as_array) + .map(|list| { + list.iter() + .filter_map(|p| { + let resolved = resolve_parameter(p, components, resolver)?; + resolved.get("name")?.as_str()?; + Some(resolved) + }) + .collect() + }) + .unwrap_or_default() + }; + + let path_level = resolve_list(path_item.get("parameters"), resolver); + let op_level = resolve_list(operation.get("parameters"), resolver); + + let op_keys: HashSet<(String, String)> = op_level.iter().map(param_key).collect(); + let mut merged: Vec = path_level + .into_iter() + .filter(|p| !op_keys.contains(¶m_key(p))) + .collect(); + merged.extend(op_level); + merged +} + +fn param_key(param: &Value) -> (String, String) { + ( + param + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + param + .get("in") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ) +} + +/// Resolve one parameter entry, following a `#/components/parameters/` +/// ref if present. Returns `None` for unresolvable refs so callers drop the +/// entry instead of keeping a nameless stub. +fn resolve_parameter( + param: &Value, + components: &Value, + resolver: &mut RefResolver<'_>, +) -> Option { + let Some(reference) = param.get("$ref").and_then(Value::as_str) else { + return Some(param.clone()); + }; + let target_name = reference.strip_prefix("#/components/parameters/")?; + let target = components.get("parameters")?.get(target_name)?; + Some(resolver.resolve(target, 0)) +} + +/// Bounded local-`$ref` inliner. +/// +/// Replaces `{"$ref": "#/..."}` nodes with their (recursively resolved) +/// targets; sibling keys next to `$ref` overlay the resolved target (the +/// OpenAPI 3.1 `summary`/`description` pattern). External refs, missing +/// targets, cycles past [`MAX_REF_DEPTH`], and documents spending more than +/// [`MAX_REF_EXPANSIONS`] lookups degrade the node to `{}` — schema +/// "anything" — rather than failing the operation. +struct RefResolver<'a> { + root: &'a Value, + expansions: usize, +} + +impl<'a> RefResolver<'a> { + fn new(root: &'a Value) -> Self { + Self { + root, + expansions: 0, + } + } + + fn resolve(&mut self, node: &Value, depth: usize) -> Value { + match node { + Value::Object(map) => { + if let Some(reference) = map.get("$ref").and_then(Value::as_str) { + let resolved = self.resolve_ref(reference, depth); + // Sibling keys overlay the resolved target. + if map.len() > 1 { + let mut base = match resolved { + Value::Object(m) => m, + _ => Map::new(), + }; + for (k, v) in map { + if k != "$ref" { + base.insert(k.clone(), self.resolve(v, depth)); + } + } + return Value::Object(base); + } + return resolved; + } + Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), self.resolve(v, depth))) + .collect(), + ) + } + Value::Array(items) => { + Value::Array(items.iter().map(|v| self.resolve(v, depth)).collect()) + } + other => other.clone(), + } + } + + fn resolve_ref(&mut self, reference: &str, depth: usize) -> Value { + if depth >= MAX_REF_DEPTH || self.expansions >= MAX_REF_EXPANSIONS { + return json!({}); + } + let Some(pointer) = reference.strip_prefix('#') else { + // External refs are not fetched at runtime by design. + return json!({}); + }; + self.expansions += 1; + match self.root.pointer(pointer) { + Some(target) => self.resolve(target, depth + 1), + None => json!({}), + } + } +} + +/// Substitute path parameters into the template and join with the base URL. +/// +/// Every `{param}` in the template must be supplied: OpenAPI path parameters +/// are required by definition, and leaving a literal `{param}` in the URL +/// (LiteLLM's behavior) produces a request that can only 404. Values are +/// checked against traversal (`/`, `\`, `.`, `..`) and percent-encoded. +fn build_url( + base_url: &str, + tool: &GeneratedTool, + args: &Map, +) -> Result { + let mut path = tool.path.clone(); + for param in &tool.path_params { + let value = args.get(param.as_str()).unwrap_or(&Value::Null); + let raw = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => return Err(format!("missing required path parameter '{param}'")), + _ => { + return Err(format!( + "path parameter '{param}' must be a string, number, or boolean" + )) + } + }; + let safe = sanitize_path_value(&raw, param)?; + path = path.replace(&format!("{{{param}}}"), &safe); + } + Ok(format!("{}{}", base_url.trim_end_matches('/'), path)) +} + +/// Reject path values that could change the request target (segment +/// separators, `.`/`..`), then percent-encode the rest. +fn sanitize_path_value(raw: &str, param: &str) -> Result { + if raw.is_empty() { + return Err(format!("missing required path parameter '{param}'")); + } + if raw.contains('/') || raw.contains('\\') { + return Err(format!( + "path parameter '{param}' must not contain path separators" + )); + } + if raw == "." || raw == ".." { + return Err(format!("path parameter '{param}' cannot be '.' or '..'")); + } + Ok(utf8_percent_encode(raw, PATH_SEGMENT_ENCODE).to_string()) +} + +/// Build the query string pairs from the declared query parameters present in +/// the arguments: scalars serialize plainly, arrays repeat the key per item, +/// and objects are JSON-encoded. +fn build_query_pairs(tool: &GeneratedTool, args: &Map) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + for param in &tool.query_params { + let Some(value) = args.get(param.as_str()) else { + continue; + }; + match value { + Value::Null => {} + Value::Array(items) => { + for item in items { + pairs.push((param.clone(), scalar_to_string(item))); + } + } + other => pairs.push((param.clone(), scalar_to_string(other))), + } + } + pairs +} + +fn scalar_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Coerce the `body` argument into the JSON body to send, mirroring LiteLLM: +/// objects and arrays pass through; a string is parsed as JSON when possible +/// and wrapped as `{"data": }` otherwise; other scalars wrap the same +/// way; null/absent means no body. +fn coerce_body(value: Option<&Value>) -> Option { + match value? { + Value::Null => None, + v @ (Value::Object(_) | Value::Array(_)) => Some(v.clone()), + Value::String(s) => match serde_json::from_str::(s) { + Ok(parsed) => Some(parsed), + Err(_) => Some(json!({ "data": s })), + }, + scalar => Some(json!({ "data": scalar })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_names(spec: &Value) -> Vec { + generate_tools(spec) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect() + } + + fn find<'a>(tools: &'a [GeneratedTool], name: &str) -> &'a GeneratedTool { + tools + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("tool {name} not generated")) + } + + #[test] + fn sanitizes_operation_ids_like_litellm() { + // GitHub-style tag-namespaced ids gain `_` for `/`; uppercase folds. + assert_eq!( + sanitize_tool_name("actions/Download-Job.Logs"), + "actions_download-job_logs" + ); + let long = "x".repeat(200); + assert_eq!(sanitize_tool_name(&long).len(), TOOL_NAME_MAX_LEN); + } + + #[test] + fn generates_tools_with_fallback_names_and_descriptions() { + let spec = json!({ + "openapi": "3.0.0", + "paths": { + "/items": { + "get": { "operationId": "listItems", "summary": "List items" }, + // No operationId: name falls back to `_`. + "post": {} + } + } + }); + let tools = generate_tools(&spec).unwrap(); + let names = tools.iter().map(|t| t.name.as_str()).collect::>(); + assert!(names.contains(&"listitems"), "{names:?}"); + assert!(names.contains(&"post__items"), "{names:?}"); + assert_eq!(find(&tools, "listitems").description, "List items"); + assert_eq!(find(&tools, "post__items").description, "POST /items"); + } + + #[test] + fn disambiguates_sanitized_name_collisions() { + let spec = json!({ + "paths": { + "/a": { "get": { "operationId": "foo/list" } }, + "/b": { "get": { "operationId": "foo.list" } } + } + }); + let mut names = tool_names(&spec); + names.sort(); + assert_eq!(names, vec!["foo_list", "foo_list_2"]); + } + + #[test] + fn builds_schema_from_params_and_body() { + let spec = json!({ + "paths": { + "/items/{id}": { + // Path-level param applies to the operation. + "parameters": [ + { "name": "id", "in": "path", "required": true, + "schema": { "type": "integer" } } + ], + "patch": { + "operationId": "updateItem", + "parameters": [ + { "name": "dry_run", "in": "query", + "description": "Validate only", + "schema": { "type": "boolean" } }, + // Header params are the gateway's, not the agent's. + { "name": "x-tenant", "in": "header", + "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "description": "Fields to update", + "content": { "application/json": { + "schema": { "type": "object", + "properties": { "note": { "type": "string" } }, + "required": ["note"] } } } + } + } + } + } + }); + let tools = generate_tools(&spec).unwrap(); + let tool = find(&tools, "updateitem"); + assert_eq!(tool.method, "patch"); + assert_eq!(tool.path, "/items/{id}"); + assert_eq!(tool.path_params, vec!["id"]); + assert_eq!(tool.query_params, vec!["dry_run"]); + assert!(tool.has_body); + + let schema = &tool.input_schema; + let props = schema.get("properties").unwrap(); + assert_eq!(props["id"]["type"], "integer"); + assert_eq!(props["dry_run"]["type"], "boolean"); + assert_eq!(props["dry_run"]["description"], "Validate only"); + assert!(props.get("x-tenant").is_none(), "header params excluded"); + assert_eq!(props["body"]["type"], "object"); + assert_eq!(props["body"]["description"], "Fields to update"); + assert_eq!(props["body"]["properties"]["note"]["type"], "string"); + let required = schema.get("required").unwrap().as_array().unwrap(); + assert!(required.contains(&json!("id"))); + assert!(required.contains(&json!("body"))); + assert!(!required.contains(&json!("dry_run"))); + } + + #[test] + fn operation_params_override_path_level_on_same_name() { + let spec = json!({ + "paths": { + "/x/{v}": { + "parameters": [ + { "name": "v", "in": "path", "required": true, + "schema": { "type": "string" } } + ], + "get": { + "operationId": "getX", + "parameters": [ + { "name": "v", "in": "path", "required": true, + "schema": { "type": "integer" } } + ] + } + } + } + }); + let tools = generate_tools(&spec).unwrap(); + let tool = find(&tools, "getx"); + assert_eq!(tool.input_schema["properties"]["v"]["type"], "integer"); + assert_eq!(tool.path_params, vec!["v"]); + } + + #[test] + fn resolves_component_refs_in_params_and_body() { + let spec = json!({ + "components": { + "parameters": { + "PerPage": { "name": "per_page", "in": "query", + "schema": { "type": "integer" } } + }, + "schemas": { + "Order": { "type": "object", + "properties": { + "sku": { "type": "string" }, + "customer": { "$ref": "#/components/schemas/Customer" } + } }, + "Customer": { "type": "object", + "properties": { "name": { "type": "string" } } } + } + }, + "paths": { + "/orders": { + "post": { + "operationId": "createOrder", + "parameters": [ { "$ref": "#/components/parameters/PerPage" } ], + "requestBody": { "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Order" } } } } + } + } + } + }); + let tools = generate_tools(&spec).unwrap(); + let tool = find(&tools, "createorder"); + assert_eq!(tool.query_params, vec!["per_page"]); + let body = &tool.input_schema["properties"]["body"]; + assert_eq!(body["properties"]["sku"]["type"], "string"); + // Nested ref resolved one level deeper. + assert_eq!( + body["properties"]["customer"]["properties"]["name"]["type"], + "string" + ); + } + + #[test] + fn cyclic_refs_degrade_to_empty_schema_instead_of_hanging() { + let spec = json!({ + "components": { "schemas": { + "Node": { "type": "object", + "properties": { "next": { "$ref": "#/components/schemas/Node" } } } + } }, + "paths": { "/nodes": { "post": { + "operationId": "createNode", + "requestBody": { "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Node" } } } } + } } } + }); + let tools = generate_tools(&spec).unwrap(); + // Terminates; the innermost expansion bottoms out at `{}`. + let body = &tools[0].input_schema["properties"]["body"]; + assert_eq!(body["type"], "object"); + } + + #[test] + fn unresolvable_and_external_refs_degrade_to_any() { + let spec = json!({ + "paths": { "/a": { "post": { + "operationId": "a", + "requestBody": { "content": { "application/json": { + "schema": { "$ref": "https://elsewhere.example/schema.json" } } } } + } } } + }); + let tools = generate_tools(&spec).unwrap(); + // External ref → `{}` → coerced to an object schema for `body`. + assert_eq!( + tools[0].input_schema["properties"]["body"]["type"], + "object" + ); + } + + #[test] + fn skips_operations_without_a_json_body_variant() { + let spec = json!({ + "paths": { + "/upload": { "post": { + "operationId": "uploadFile", + "requestBody": { "content": { "multipart/form-data": { + "schema": { "type": "object" } } } } + } }, + "/ok": { "post": { + "operationId": "jsonVariant", + "requestBody": { "content": { + "application/json; charset=utf-8": { "schema": { "type": "object" } } + } } + } }, + "/problem": { "post": { + "operationId": "problemJson", + "requestBody": { "content": { + "application/problem+json": { "schema": { "type": "object" } } + } } + } } + } + }); + let mut names = tool_names(&spec); + names.sort(); + // `uploadFile` is absent; parameterized and `+json` variants count. + assert_eq!(names, vec!["jsonvariant", "problemjson"]); + } + + #[test] + fn spec_without_paths_is_an_error() { + let err = generate_tools(&json!({ "openapi": "3.0.0" })).unwrap_err(); + assert!(err.to_string().contains("paths"), "{err}"); + } + + #[test] + fn build_url_substitutes_encodes_and_rejects_traversal() { + let tool = GeneratedTool { + name: "t".into(), + description: String::new(), + input_schema: json!({}), + method: "get".into(), + path: "/items/{id}/sub".into(), + path_params: vec!["id".into()], + query_params: vec![], + has_body: false, + }; + let args = |v: Value| { + let mut m = Map::new(); + m.insert("id".into(), v); + m + }; + + assert_eq!( + build_url("https://api.example.com/v1/", &tool, &args(json!("a b#c"))).unwrap(), + "https://api.example.com/v1/items/a%20b%23c/sub" + ); + assert_eq!( + build_url("https://api.example.com", &tool, &args(json!(42))).unwrap(), + "https://api.example.com/items/42/sub" + ); + for bad in [ + json!("../etc"), + json!("a/b"), + json!("a\\b"), + json!(".."), + json!("."), + ] { + assert!( + build_url("https://api.example.com", &tool, &args(bad.clone())).is_err(), + "expected rejection for {bad}" + ); + } + let missing = Map::new(); + let err = build_url("https://api.example.com", &tool, &missing).unwrap_err(); + assert!(err.to_string().contains("missing required path parameter")); + } + + #[test] + fn query_pairs_serialize_scalars_arrays_and_objects() { + let tool = GeneratedTool { + name: "t".into(), + description: String::new(), + input_schema: json!({}), + method: "get".into(), + path: "/".into(), + path_params: vec![], + query_params: vec!["q".into(), "tags".into(), "filter".into(), "absent".into()], + has_body: false, + }; + let mut args = Map::new(); + args.insert("q".into(), json!("text")); + args.insert("tags".into(), json!(["a", 2])); + args.insert("filter".into(), json!({"k": "v"})); + args.insert("undeclared".into(), json!("dropped")); + assert_eq!( + build_query_pairs(&tool, &args), + vec![ + ("q".to_string(), "text".to_string()), + ("tags".to_string(), "a".to_string()), + ("tags".to_string(), "2".to_string()), + ("filter".to_string(), r#"{"k":"v"}"#.to_string()), + ] + ); + } + + #[test] + fn validate_spec_rejects_duplicates_and_empty_specs() { + // Collision: strict validation names the colliding base name. + let dup = json!({ + "paths": { + "/a": { "get": { "operationId": "foo/list" } }, + "/b": { "get": { "operationId": "foo.list" } } + } + }); + let err = validate_spec(&dup).unwrap_err().to_string(); + assert!(err.contains("duplicate tool names"), "{err}"); + assert!(err.contains("foo_list"), "{err}"); + + // No paths at all. + assert!(validate_spec(&json!({ "openapi": "3.0.0" })).is_err()); + + // Paths but nothing generatable — the skipped multipart op is named. + let only_multipart = json!({ + "paths": { "/upload": { "post": { + "operationId": "up", + "requestBody": { "content": { "multipart/form-data": {} } } + } } } + }); + let err = validate_spec(&only_multipart).unwrap_err().to_string(); + assert!(err.contains("no operations"), "{err}"); + assert!(err.contains("POST /upload"), "{err}"); + + // Healthy spec: returns the generated names. + let ok = json!({ + "paths": { "/items": { "get": { "operationId": "listItems" } } } + }); + assert_eq!(validate_spec(&ok).unwrap(), vec!["listitems"]); + } + + #[test] + fn coerce_body_matches_litellm_semantics() { + assert_eq!(coerce_body(Some(&json!({"a": 1}))), Some(json!({"a": 1}))); + assert_eq!( + coerce_body(Some(&json!(r#"{"parsed": true}"#))), + Some(json!({"parsed": true})) + ); + assert_eq!( + coerce_body(Some(&json!("plain text"))), + Some(json!({"data": "plain text"})) + ); + assert_eq!(coerce_body(Some(&json!(5))), Some(json!({"data": 5}))); + assert_eq!(coerce_body(Some(&Value::Null)), None); + assert_eq!(coerce_body(None), None); + } +} diff --git a/crates/aisix-mcp/tests/openapi_tool_roundtrip.rs b/crates/aisix-mcp/tests/openapi_tool_roundtrip.rs new file mode 100644 index 00000000..7afb6370 --- /dev/null +++ b/crates/aisix-mcp/tests/openapi_tool_roundtrip.rs @@ -0,0 +1,336 @@ +//! End-to-end test of an OpenAPI-backed server behind the MCP gateway: a real +//! REST API (axum, ephemeral port) is registered as a `type: openapi` +//! `mcp_server`, and a real rmcp client drives the gateway's `/mcp` endpoint. +//! +//! Pins the issue-level acceptance criteria at the crate boundary: +//! - `tools/list` exposes one namespaced tool per spec operation with the +//! generated input schema; +//! - `tools/call` executes the REST request — path substitution, query +//! parameters, JSON body, and the gateway-held credential (never supplied +//! by the agent); +//! - a non-2xx response and an argument mistake surface as tool-level errors +//! (`isError: true`), not protocol errors. + +use std::collections::HashMap; +use std::net::SocketAddr; + +use aisix_core::{AisixSnapshot, McpServer, ResourceEntry}; +use aisix_mcp::{streamable_http_service, McpGateway}; +use axum::extract::{Path, Query}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::Json; +use rmcp::model::{CallToolRequestParams, CallToolResult}; +use rmcp::transport::StreamableHttpClientTransport; +use rmcp::ServiceExt; +use serde_json::{json, Value}; + +/// The bearer token the gateway holds for the fake ERP API. The REST handlers +/// 401 without it, proving the credential is injected gateway-side. +const ERP_TOKEN: &str = "tok-erp-123"; + +/// The custom API-key header (and key) for the second, `api_key`-mode server. +const INVENTORY_HEADER: &str = "x-inventory-key"; +const INVENTORY_KEY: &str = "inv-key-456"; + +async fn serve(app: axum::Router) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + addr +} + +/// A fake ERP REST API: bearer-authenticated echo endpoints. +async fn spawn_erp_api() -> SocketAddr { + fn authed(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {ERP_TOKEN}")) + } + + let app = axum::Router::new() + .route( + "/v1/items/:id", + get( + |headers: HeaderMap, + Path(id): Path, + Query(q): Query>| async move { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "no auth"}))) + .into_response(); + } + Json(json!({ "id": id, "query": q })).into_response() + }, + ), + ) + .route( + "/v1/orders", + post(|headers: HeaderMap, Json(body): Json| async move { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "no auth"}))) + .into_response(); + } + Json(json!({ "created": body })).into_response() + }), + ) + .route( + "/v1/fail", + get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "boom") }), + ); + serve(app).await +} + +/// The ERP API's OpenAPI document, as the control plane would materialize it. +fn erp_spec() -> Value { + json!({ + "openapi": "3.0.0", + "info": { "title": "ERP", "version": "1.0.0" }, + "paths": { + "/items/{id}": { + "get": { + "operationId": "getItem", + "summary": "Fetch one item", + "parameters": [ + { "name": "id", "in": "path", "required": true, + "schema": { "type": "integer" } }, + { "name": "verbose", "in": "query", + "schema": { "type": "boolean" } } + ] + } + }, + "/orders": { + "post": { + "operationId": "createOrder", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "properties": { "note": { "type": "string" } }, + "required": ["note"] + } } } + } + } + }, + "/fail": { "get": { "operationId": "failOp" } } + } + }) +} + +fn openapi_entry(id: &str, config: Value) -> ResourceEntry { + let server: McpServer = serde_json::from_value(config).expect("valid mcp_server resource"); + ResourceEntry::new(id, server, 1) +} + +async fn spawn_gateway(gateway: McpGateway) -> SocketAddr { + serve(axum::Router::new().nest_service("/mcp", streamable_http_service(gateway))).await +} + +fn first_text(result: &CallToolResult) -> String { + let value = serde_json::to_value(&result.content).expect("encode content"); + value[0]["text"].as_str().unwrap_or_default().to_string() +} + +fn call(name: &str, args: Value) -> CallToolRequestParams { + let mut params = CallToolRequestParams::new(name.to_string()); + if let Value::Object(map) = args { + params = params.with_arguments(map); + } + params +} + +#[tokio::test] +async fn openapi_server_lists_and_calls_generated_tools() { + let api = spawn_erp_api().await; + + let snapshot = AisixSnapshot::new(); + snapshot.mcp_servers.insert(openapi_entry( + "e1", + json!({ + "name": "erp", + "type": "openapi", + "url": format!("http://{api}/v1"), + "spec": erp_spec(), + "auth_type": "bearer", + "secret": ERP_TOKEN, + }), + )); + + let gw = spawn_gateway(McpGateway::from_snapshot(&snapshot)).await; + let client = () + .serve(StreamableHttpClientTransport::from_uri(format!( + "http://{gw}/mcp" + ))) + .await + .expect("connect downstream client"); + + // tools/list: one namespaced tool per operation, schema preserved. + let tools = client.list_tools(None).await.expect("list tools"); + let mut names: Vec<_> = tools.tools.iter().map(|t| t.name.to_string()).collect(); + names.sort(); + assert_eq!( + names, + vec!["erp__createorder", "erp__failop", "erp__getitem"] + ); + + let get_item = tools + .tools + .iter() + .find(|t| t.name == "erp__getitem") + .expect("getItem generated"); + let schema = serde_json::to_value(&get_item.input_schema).expect("schema"); + assert_eq!(schema["properties"]["id"]["type"], "integer"); + assert_eq!(schema["properties"]["verbose"]["type"], "boolean"); + assert_eq!(schema["required"], json!(["id"])); + + // tools/call GET: path substitution + query serialization + gateway-held + // bearer (the client never sent a credential). + let result = client + .call_tool(call("erp__getitem", json!({ "id": 42, "verbose": true }))) + .await + .expect("call getItem"); + assert_ne!(result.is_error, Some(true), "unexpected tool error"); + let echoed: Value = serde_json::from_str(&first_text(&result)).expect("json echo"); + assert_eq!(echoed["id"], "42"); + assert_eq!(echoed["query"]["verbose"], "true"); + + // tools/call POST: the `body` argument becomes the JSON request body. + let result = client + .call_tool(call( + "erp__createorder", + json!({ "body": { "note": "hello" } }), + )) + .await + .expect("call createOrder"); + assert_ne!(result.is_error, Some(true)); + let echoed: Value = serde_json::from_str(&first_text(&result)).expect("json echo"); + assert_eq!(echoed["created"]["note"], "hello"); + + // Non-2xx → tool-level error carrying the status and body. + let result = client + .call_tool(call("erp__failop", Value::Null)) + .await + .expect("call failOp"); + assert_eq!(result.is_error, Some(true)); + let text = first_text(&result); + assert!(text.starts_with("HTTP 500:"), "got: {text}"); + assert!(text.contains("boom"), "got: {text}"); + + // Argument mistake (missing required path param) → tool-level error the + // agent can read and fix, not an opaque protocol error. + let result = client + .call_tool(call("erp__getitem", json!({ "verbose": true }))) + .await + .expect("call getItem without id"); + assert_eq!(result.is_error, Some(true)); + assert!( + first_text(&result).contains("missing required path parameter 'id'"), + "got: {}", + first_text(&result) + ); + + client.cancel().await.ok(); +} + +#[tokio::test] +async fn openapi_server_sends_api_key_under_configured_header() { + // Echo back the configured custom header so the assertion sees exactly + // what the gateway sent. + let app = axum::Router::new().route( + "/lookup", + get(|headers: HeaderMap| async move { + let key = headers + .get(INVENTORY_HEADER) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + Json(json!({ "received_key": key })) + }), + ); + let api = serve(app).await; + + let snapshot = AisixSnapshot::new(); + snapshot.mcp_servers.insert(openapi_entry( + "e2", + json!({ + "name": "inventory", + "type": "openapi", + "url": format!("http://{api}"), + "spec": { + "openapi": "3.0.0", + "paths": { "/lookup": { "get": { "operationId": "lookup" } } } + }, + "auth_type": "api_key", + "secret": INVENTORY_KEY, + "api_key_header": INVENTORY_HEADER, + }), + )); + + let gw = spawn_gateway(McpGateway::from_snapshot(&snapshot)).await; + let client = () + .serve(StreamableHttpClientTransport::from_uri(format!( + "http://{gw}/mcp" + ))) + .await + .expect("connect downstream client"); + + let result = client + .call_tool(call("inventory__lookup", Value::Null)) + .await + .expect("call lookup"); + assert_ne!(result.is_error, Some(true)); + let echoed: Value = serde_json::from_str(&first_text(&result)).expect("json echo"); + assert_eq!(echoed["received_key"], INVENTORY_KEY); + + client.cancel().await.ok(); +} + +#[tokio::test] +async fn broken_spec_degrades_gracefully_next_to_healthy_servers() { + // One healthy openapi server and one whose spec is unusable: the broken + // one's tools are absent, the healthy one keeps serving — mirroring how a + // dead real upstream degrades. + let api = spawn_erp_api().await; + + let snapshot = AisixSnapshot::new(); + snapshot.mcp_servers.insert(openapi_entry( + "e1", + json!({ + "name": "erp", + "type": "openapi", + "url": format!("http://{api}/v1"), + "spec": erp_spec(), + "auth_type": "bearer", + "secret": ERP_TOKEN, + }), + )); + snapshot.mcp_servers.insert(openapi_entry( + "e2", + json!({ + "name": "broken", + "type": "openapi", + "url": "http://127.0.0.1:1/api", + "spec": { "openapi": "3.0.0" } + }), + )); + + let gw = spawn_gateway(McpGateway::from_snapshot(&snapshot)).await; + let client = () + .serve(StreamableHttpClientTransport::from_uri(format!( + "http://{gw}/mcp" + ))) + .await + .expect("connect downstream client"); + + let tools = client.list_tools(None).await.expect("list tools"); + let names: Vec<_> = tools.tools.iter().map(|t| t.name.to_string()).collect(); + assert!(names.iter().all(|n| n.starts_with("erp__")), "{names:?}"); + assert_eq!(names.len(), 3); + + client.cancel().await.ok(); +} diff --git a/schemas/resources/mcp_server.schema.json b/schemas/resources/mcp_server.schema.json index 7423983a..836c46f4 100644 --- a/schemas/resources/mcp_server.schema.json +++ b/schemas/resources/mcp_server.schema.json @@ -53,6 +53,27 @@ } ] }, + "McpServerType": { + "description": "What backs a registered MCP server entry.", + "oneOf": [ + { + "description": "A real upstream MCP server the gateway connects to.", + "enum": [ + "mcp" + ], + "title": "Upstream MCP server", + "type": "string" + }, + { + "description": "A REST API described by an OpenAPI document; the gateway generates the tools itself and issues plain HTTP requests against `url`.", + "enum": [ + "openapi" + ], + "title": "REST API described by an OpenAPI document", + "type": "string" + } + ] + }, "McpTransport": { "description": "Transport used to reach an upstream MCP server.", "oneOf": [ @@ -68,6 +89,14 @@ } }, "properties": { + "api_key_header": { + "description": "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.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, "auth_type": { "allOf": [ { @@ -110,12 +139,15 @@ ] }, "secret": { - "description": "Authentication credential for the upstream server. Its meaning follows `auth_type`: the bearer token when `auth_type` is `bearer` (sent as `Authorization: Bearer `), the API key when `auth_type` is `api_key` (sent as `x-api-key: `), or the OAuth client secret when `auth_type` is `oauth2`. Leave unset when `auth_type` is `none`.", + "description": "Authentication credential for the upstream server. Its meaning follows `auth_type`: the bearer token when `auth_type` is `bearer` (sent as `Authorization: Bearer `), the API key when `auth_type` is `api_key` (sent as `x-api-key: `, 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`.", "type": [ "string", "null" ] }, + "spec": { + "description": "The OpenAPI 3.x document (as a JSON object) whose operations become this server's tools. Required when `type` is `openapi`; ignored otherwise." + }, "timeout_ms": { "description": "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.", "format": "uint64", @@ -141,8 +173,17 @@ "default": "streamable_http", "description": "Transport used to reach the upstream server. Streamable HTTP is the only supported transport." }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/McpServerType" + } + ], + "default": "mcp", + "description": "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." + }, "url": { - "description": "The upstream server's MCP endpoint URL, reached over the Streamable HTTP transport, such as `https://api.example.com/mcp`.", + "description": "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`.", "minLength": 1, "type": "string" } diff --git a/tests/e2e/src/cases/mcp-openapi-e2e.test.ts b/tests/e2e/src/cases/mcp-openapi-e2e.test.ts new file mode 100644 index 00000000..c199d128 --- /dev/null +++ b/tests/e2e/src/cases/mcp-openapi-e2e.test.ts @@ -0,0 +1,307 @@ +import { createHash, randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + startRestUpstream, + waitConfigPropagation, + type RestUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: an OpenAPI-backed MCP server (`type: openapi`) against a real gateway +// + etcd + a real (fake-ERP) REST upstream. No MCP upstream exists — the +// gateway generates the tools from the registered OpenAPI document and +// executes tool calls as plain HTTP requests. +// +// Pinned contract (the issue's acceptance criteria at the binary level): +// - `tools/list` exposes one namespaced tool per spec operation, with the +// input schema generated from the spec (params + `body` property); +// - `tools/call` performs the REST request: path substitution, query +// serialization, JSON body, and the gateway-held bearer credential the +// agent never sees; +// - a non-2xx REST response surfaces as a tool-level `isError` result with +// the status and body; +// - the per-tool ACL governs generated tools exactly like real MCP tools: +// a key scoped to one tool neither lists nor calls the others. + +const KEY_FULL = "sk-mcp-openapi-full"; +const KEY_SCOPED = "sk-mcp-openapi-scoped"; + +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); + +interface ToolDef { + name: string; + description?: string; + inputSchema?: { + type?: string; + properties?: Record; + required?: string[]; + }; +} + +interface RpcReply { + status: number; + json?: { + result?: { + tools?: ToolDef[]; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + }; + error?: { code: number; message: string }; + }; +} + +describe("mcp openapi e2e: REST API exposed as MCP tools", () => { + let app: SpawnedApp | undefined; + let erp: RestUpstream | undefined; + let etcdReachable = false; + + 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-openapi-e2e", version: "0.1" }, + }, + }); + await post(token, { jsonrpc: "2.0", method: "notifications/initialized" }); + return init.status; + }; + + const listTools = async (token: string): Promise => { + await initialize(token); + const r = await post(token, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + expect(r.status).toBe(200); + return r.json?.result?.tools ?? []; + }; + + const callTool = async ( + token: string, + name: string, + args: unknown, + ): Promise<{ + status: number; + isError?: boolean; + text?: string; + rpcError?: string; + }> => { + const r = await post(token, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name, arguments: args }, + }); + return { + status: r.status, + isError: r.json?.result?.isError, + text: r.json?.result?.content?.[0]?.text, + rpcError: r.json?.error?.message, + }; + }; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + erp = await startRestUpstream(); + app = await spawnApp(); + const seed = new SeedClient(etcd, app.etcdPrefix); + + await seed.update("mcp_servers", randomUUID(), { + name: "erp", + type: "openapi", + url: erp.baseUrl, + auth_type: "bearer", + secret: erp.token, + enabled: true, + spec: { + openapi: "3.0.0", + info: { title: "ERP", version: "1.0.0" }, + paths: { + "/items/{id}": { + get: { + operationId: "getItem", + summary: "Fetch one item", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + }, + { + name: "verbose", + in: "query", + description: "Include details", + schema: { type: "boolean" }, + }, + ], + }, + }, + "/orders": { + post: { + operationId: "createOrder", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { note: { type: "string" } }, + required: ["note"], + }, + }, + }, + }, + }, + }, + "/fail": { get: { operationId: "failOp" } }, + }, + }, + }); + + await seed.createApiKey({ + key_hash: sha256(KEY_FULL), + allowed_models: [], + allowed_tools: ["*"], + }); + await seed.createApiKey({ + key_hash: sha256(KEY_SCOPED), + allowed_models: [], + allowed_tools: ["erp__getitem"], + }); + + // Tolerant probe (no assertions): both the key and the server must have + // propagated before the pinned tests run. + await waitConfigPropagation(async () => { + if ((await initialize(KEY_FULL)) !== 200) return false; + const r = await post(KEY_FULL, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + return (r.json?.result?.tools ?? []).length === 3; + }); + }, 120_000); + + afterAll(async () => { + await app?.exit(); + await erp?.close(); + }); + + test("tools/list exposes generated tools with spec-derived schemas", async () => { + if (!etcdReachable) return; + const tools = await listTools(KEY_FULL); + const names = tools.map((t) => t.name).sort(); + expect(names).toEqual(["erp__createorder", "erp__failop", "erp__getitem"]); + + const getItem = tools.find((t) => t.name === "erp__getitem")!; + expect(getItem.description).toBe("Fetch one item"); + expect(getItem.inputSchema?.properties?.id?.type).toBe("integer"); + expect(getItem.inputSchema?.properties?.verbose?.type).toBe("boolean"); + expect(getItem.inputSchema?.properties?.verbose?.description).toBe( + "Include details", + ); + expect(getItem.inputSchema?.required).toEqual(["id"]); + + const createOrder = tools.find((t) => t.name === "erp__createorder")!; + expect(createOrder.inputSchema?.properties?.body?.type).toBe("object"); + expect(createOrder.inputSchema?.required).toEqual(["body"]); + }); + + test("tools/call executes GET with path + query + gateway-held auth", async () => { + if (!etcdReachable) return; + await initialize(KEY_FULL); + const r = await callTool(KEY_FULL, "erp__getitem", { + id: 42, + verbose: true, + }); + expect(r.status).toBe(200); + expect(r.isError).not.toBe(true); + const echoed = JSON.parse(r.text ?? "{}"); + // The REST server 401s without the gateway-held bearer, so a successful + // echo proves the credential was injected gateway-side. + expect(echoed.id).toBe("42"); + expect(echoed.query.verbose).toBe("true"); + }); + + test("tools/call executes POST with the body argument as JSON body", async () => { + if (!etcdReachable) return; + await initialize(KEY_FULL); + const r = await callTool(KEY_FULL, "erp__createorder", { + body: { note: "from-e2e" }, + }); + expect(r.status).toBe(200); + expect(r.isError).not.toBe(true); + const echoed = JSON.parse(r.text ?? "{}"); + expect(echoed.created.note).toBe("from-e2e"); + }); + + test("non-2xx REST response surfaces as a tool-level error result", async () => { + if (!etcdReachable) return; + await initialize(KEY_FULL); + const r = await callTool(KEY_FULL, "erp__failop", {}); + expect(r.status).toBe(200); + expect(r.isError).toBe(true); + expect(r.text).toContain("HTTP 500"); + expect(r.text).toContain("boom"); + }); + + test("missing required path parameter is a readable tool-level error", async () => { + if (!etcdReachable) return; + await initialize(KEY_FULL); + const r = await callTool(KEY_FULL, "erp__getitem", { verbose: true }); + expect(r.status).toBe(200); + expect(r.isError).toBe(true); + expect(r.text).toContain("missing required path parameter 'id'"); + }); + + test("per-tool ACL applies to generated tools", async () => { + if (!etcdReachable) return; + const scoped = await listTools(KEY_SCOPED); + expect(scoped.map((t) => t.name)).toEqual(["erp__getitem"]); + + await initialize(KEY_SCOPED); + const denied = await callTool(KEY_SCOPED, "erp__createorder", { + body: { note: "nope" }, + }); + expect(denied.rpcError ?? "").toContain("not available"); + + const allowed = await callTool(KEY_SCOPED, "erp__getitem", { id: 7 }); + expect(allowed.isError).not.toBe(true); + expect(JSON.parse(allowed.text ?? "{}").id).toBe("7"); + }); +}); diff --git a/tests/e2e/src/harness/index.ts b/tests/e2e/src/harness/index.ts index 13617a13..f5bd5ce6 100644 --- a/tests/e2e/src/harness/index.ts +++ b/tests/e2e/src/harness/index.ts @@ -5,6 +5,7 @@ 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 { startRestUpstream, type RestUpstream } from "./upstream-rest.js"; export { pickFreePort, pickFreePorts } from "./ports.js"; export { startMockSls, diff --git a/tests/e2e/src/harness/upstream-rest.ts b/tests/e2e/src/harness/upstream-rest.ts new file mode 100644 index 00000000..26a942c8 --- /dev/null +++ b/tests/e2e/src/harness/upstream-rest.ts @@ -0,0 +1,93 @@ +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from "node:http"; + +export interface RestUpstream { + /** Base URL of the fake REST API (`http://127.0.0.1:/v1`). */ + baseUrl: string; + /** The bearer token the API requires on its authenticated routes. */ + token: string; + close(): Promise; +} + +/** + * A fake "ERP" REST API for OpenAPI-backed MCP server tests — a plain HTTP + * server, no MCP involved. Routes (all under `/v1`, bearer-authenticated + * except `/fail`): + * - `GET /v1/items/` → echoes `{ id, query }` (path + query params) + * - `POST /v1/orders` → echoes `{ created: }` + * - `GET /v1/fail` → always `500 boom` + * + * The matching OpenAPI document lives with the test case; this server is the + * live interop partner its generated tools are called against. + */ +export async function startRestUpstream(): Promise { + const token = "tok-erp-e2e"; + + const httpServer: HttpServer = createServer((req, res) => { + void handle(token, req, res); + }); + await new Promise((resolve) => + httpServer.listen(0, "127.0.0.1", resolve), + ); + const address = httpServer.address(); + if (address === null || typeof address === "string") { + throw new Error("rest upstream: no listen address"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + token, + close: () => new Promise((resolve) => httpServer.close(() => resolve())), + }; +} + +async function handle( + token: string, + req: IncomingMessage, + res: ServerResponse, +): Promise { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const send = (status: number, body: unknown): void => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + + if (req.method === "GET" && url.pathname === "/v1/fail") { + res.writeHead(500, { "content-type": "text/plain" }); + res.end("boom"); + return; + } + + if (req.headers.authorization !== `Bearer ${token}`) { + send(401, { error: "no auth" }); + return; + } + + const itemMatch = /^\/v1\/items\/([^/]+)$/.exec(url.pathname); + if (req.method === "GET" && itemMatch) { + send(200, { + id: decodeURIComponent(itemMatch[1]), + query: Object.fromEntries(url.searchParams), + }); + return; + } + + if (req.method === "POST" && url.pathname === "/v1/orders") { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + let body: unknown; + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + send(400, { error: "invalid json" }); + return; + } + send(200, { created: body }); + return; + } + + send(404, { error: "not found" }); +}