Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,26 @@ jobs:
exit 1
fi

mcp-conformance:
# The OFFICIAL MCP conformance suite against the shipped /mcp chain
# (scoped gateway + bridge + in-process upstream). Scenario list and
# deliberate exclusions live in scripts/mcp-conformance.sh; the suite
# needs Node >= 22 (fs.globSync).
name: mcp conformance (official suite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-protoc
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v6
with:
node-version: 22
- name: build the conformance target
run: cargo build -p aisix-mcp --example conformance_server
- name: run the applicable server scenarios
run: bash scripts/mcp-conformance.sh

coverage-gate:
name: coverage >= 90%
needs: [rust-unit, e2e]
Expand Down
4 changes: 4 additions & 0 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,10 @@ fn add_variant_titles(doc: &mut Value) {
"/components/schemas/McpAccessMode/oneOf",
&["Inherit", "Restrict", "Deny"],
),
(
"/components/schemas/McpProtocolVersion/oneOf",
&["MCP 2026-07-28"],
),
(
// Model's top-level direct/routing/ensemble/semantic
// mutual-exclusion `oneOf` (injected by
Expand Down
11 changes: 6 additions & 5 deletions crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ pub use models::{
validate_rate_limit_policy, A2aAgent, A2aAuthType, A2aProtocolVersion, Adapter, AisixSnapshot,
ApiKey, AppliedGuardrail, CachePolicy, CooldownConfig, ExporterKind, Guardrail,
GuardrailExecution, GuardrailHookPoint, GuardrailKind, GuardrailMetricsSink,
GuardrailMonitorHit, KeywordConfig, KeywordPattern, McpAuthType, McpRateLimit, McpServer,
McpServerType, McpTransport, Model, ObservabilityExporter, ParamConstraints, PolicyScope,
PolicyWindow, ProviderKey, RateLimit, RateLimitPolicy, RequestOverrides, ResponseOverrides,
Routing, RoutingStrategy, RoutingTarget, SchemaError, StreamDoneMarker, TelemetryKind,
TelemetryTags, WhenAllUnavailablePolicy, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
GuardrailMonitorHit, KeywordConfig, KeywordPattern, McpAuthType, McpProtocolVersion,
McpRateLimit, McpServer, 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
26 changes: 26 additions & 0 deletions crates/aisix-core/src/models/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ pub struct McpServer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,

/// MCP protocol revision the gateway uses when connecting to this
/// upstream server. When omitted, the gateway opens the session with the
/// `initialize` handshake, which negotiates among the pre-2026 protocol
/// revisions — the right choice for most servers, including `2026-07-28`
/// servers that keep backward compatibility. Set `2026-07-28` for a
/// server that requires the stateless MCP `2026-07-28` revision
/// (handshake-free `server/discover` startup); the connection fails
/// rather than silently downgrading when the server does not support the
/// configured revision. Only used when `type` is `mcp`; ignored for
/// `type: openapi`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<McpProtocolVersion>,

/// Maximum time, in milliseconds, to wait for a single upstream operation
/// (establishing the session, listing tools, or calling a tool). Must be at
/// least `1` when set. When omitted, the gateway applies a built-in default.
Expand Down Expand Up @@ -140,6 +153,18 @@ pub enum McpServerType {
Openapi,
}

/// MCP protocol revision used for an upstream connection. Values are the
/// specification's dated version identifiers; revisions before `2026-07-28`
/// need no entry here because the `initialize` handshake negotiates among
/// them automatically when `protocol_version` is omitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub enum McpProtocolVersion {
/// The stateless MCP `2026-07-28` revision: handshake-free startup via
/// `server/discover`, with self-contained per-request metadata.
#[serde(rename = "2026-07-28")]
V20260728,
}

/// Transport used to reach an upstream MCP server.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
Expand Down Expand Up @@ -620,6 +645,7 @@ mod tests {
client_id: None,
token_url: None,
scopes: None,
protocol_version: None,
timeout_ms: None,
enabled: true,
runtime_id: String::new(),
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub use guardrail::{
PiiConfig, PiiCustomPattern, PiiDetectorConfig, PresidioConfig, PresidioEntityConfig,
};
pub use mcp_policy::{McpAccess, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope};
pub use mcp_server::{McpAuthType, McpServer, McpServerType, McpTransport};
pub use mcp_server::{McpAuthType, McpProtocolVersion, McpServer, McpServerType, McpTransport};
pub use model::{
Adapter, BackgroundModelCheck, CooldownConfig, Model, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
};
Expand Down
93 changes: 75 additions & 18 deletions crates/aisix-mcp/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ use std::time::Duration;
use aisix_core::{McpAuthType, McpServer};
use async_trait::async_trait;
use http::{HeaderName, HeaderValue};
use rmcp::model::{CallToolRequestParams, CallToolResponse};
use rmcp::model::{CallToolRequestParams, CallToolResponse, ClientInfo, ProtocolVersion};
use rmcp::service::{ClientInitializeError, RoleClient, RunningService};
use rmcp::transport::streamable_http_client::{
StreamableHttpClientTransportConfig, StreamableHttpError,
};
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::ClientCacheConfig;
use rmcp::ServiceExt;
use rmcp::{ClientLifecycleMode, ClientServiceExt};

use crate::error::McpError;

Expand Down Expand Up @@ -187,6 +187,26 @@ impl std::fmt::Debug for McpAuth {
}
}

/// The MCP protocol revision the bridge opens an upstream session with.
///
/// Explicit, never probed: a server that does not speak the configured
/// revision produces a visible connect failure instead of a silent
/// cross-generation downgrade. (Automatic fallback is how version and
/// session context ends up crossing the protocol boundary — the bug class
/// sibling gateways are currently working through.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum McpProtocol {
/// Open with the `initialize` handshake and negotiate among the
/// pre-2026 protocol revisions. Works with every legacy server and with
/// `2026-07-28` servers that keep backward compatibility.
#[default]
LegacyHandshake,
/// The stateless MCP `2026-07-28` revision: handshake-free
/// `server/discover` startup, self-contained per-request metadata.
/// Required for servers that no longer answer `initialize`.
V20260728,
}

/// Connection parameters for a single upstream MCP server.
#[derive(Clone)]
pub struct McpUpstream {
Expand All @@ -197,6 +217,9 @@ pub struct McpUpstream {
pub auth: McpAuth,
/// Per-operation deadline. Defaults to [`DEFAULT_UPSTREAM_TIMEOUT`].
pub timeout: Duration,
/// Protocol revision the session is opened with. Defaults to the
/// legacy `initialize` handshake.
pub protocol: McpProtocol,
}

// Manual so a `Bearer` token cannot leak through `McpUpstream`'s `Debug`
Expand All @@ -207,6 +230,7 @@ impl std::fmt::Debug for McpUpstream {
.field("url", &self.url)
.field("auth", &self.auth)
.field("timeout", &self.timeout)
.field("protocol", &self.protocol)
.finish()
}
}
Expand All @@ -218,9 +242,16 @@ impl McpUpstream {
url: url.into(),
auth: McpAuth::None,
timeout: DEFAULT_UPSTREAM_TIMEOUT,
protocol: McpProtocol::default(),
}
}

/// Select the protocol revision the session is opened with.
pub fn with_protocol(mut self, protocol: McpProtocol) -> Self {
self.protocol = protocol;
self
}

/// Set Bearer auth (raw token, no `Bearer ` prefix).
pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
self.auth = McpAuth::Bearer(token.into());
Expand Down Expand Up @@ -296,7 +327,7 @@ pub trait McpBridge: Send + Sync {
/// `rmcp`-backed [`McpBridge`]: holds one running client session to the
/// upstream. Dropping it tears the session down.
pub struct RmcpBridge {
running: RunningService<RoleClient, ()>,
running: RunningService<RoleClient, ClientInfo>,
timeout: Duration,
}

Expand Down Expand Up @@ -370,22 +401,40 @@ impl RmcpBridge {
)
}
};
().serve(transport).await.map_err(|e| {
// An upstream 401 against a minted token means the token was
// revoked or expired earlier than promised: drop the cache
// entry so the next attempt re-mints instead of replaying it.
if let McpAuth::OAuth2(cfg) = &upstream.auth {
if init_error_is_unauthorized(&e) {
crate::oauth::invalidate(cfg);
// Lifecycle follows the configured protocol revision. The
// handler is `ClientInfo::default()` on BOTH paths — identical
// handshake bytes to the previous unit handler (whose default
// `get_info()` returns exactly `ClientInfo::default()`), plus
// the per-request metadata the Discover lifecycle needs.
let lifecycle = match upstream.protocol {
McpProtocol::LegacyHandshake => ClientLifecycleMode::Initialize,
// Discover, NOT Auto: a server that answers `server/discover`
// with an error must fail the connect visibly. `Auto` would
// silently retry the legacy handshake, hiding real
// misconfiguration and version drift.
McpProtocol::V20260728 => ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
};
ClientInfo::default()
.serve_with_lifecycle(transport, lifecycle)
.await
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.map_err(|e| {
// An upstream 401 against a minted token means the token was
// revoked or expired earlier than promised: drop the cache
// entry so the next attempt re-mints instead of replaying it.
if let McpAuth::OAuth2(cfg) = &upstream.auth {
if init_error_is_unauthorized(&e) {
crate::oauth::invalidate(cfg);
}
}
}
// Bound + sanitize: a bare-401 shape embeds the upstream's
// response body in the error text, which lands in gateway
// logs. An upstream that reflects request headers into its
// error body (or emits control characters for log injection)
// must not get either past this point verbatim.
McpError::Connect(sanitize_error_message(&e.to_string()))
})
// Bound + sanitize: a bare-401 shape embeds the upstream's
// response body in the error text, which lands in gateway
// logs. An upstream that reflects request headers into its
// error body (or emits control characters for log injection)
// must not get either past this point verbatim.
McpError::Connect(sanitize_error_message(&e.to_string()))
})
};
let running = tokio::time::timeout(upstream.timeout, establish)
.await
Expand Down Expand Up @@ -633,10 +682,18 @@ pub fn upstream_from_mcp_server(server: &McpServer) -> McpUpstream {
.timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_UPSTREAM_TIMEOUT);
// Dated spec revisions map one-to-one onto session lifecycles; absent
// means the legacy handshake (which negotiates among the pre-2026
// revisions on its own).
let protocol = match server.protocol_version {
None => McpProtocol::LegacyHandshake,
Some(aisix_core::McpProtocolVersion::V20260728) => McpProtocol::V20260728,
};
McpUpstream {
url: server.url.clone(),
auth,
timeout,
protocol,
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/aisix-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ mod oauth;
pub mod openapi;

pub use bridge::{
upstream_from_mcp_server, EphemeralBridge, McpAuth, McpBridge, McpTool, McpToolResult,
McpUpstream, OAuthClientConfig, RmcpBridge,
upstream_from_mcp_server, EphemeralBridge, McpAuth, McpBridge, McpProtocol, McpTool,
McpToolResult, McpUpstream, OAuthClientConfig, RmcpBridge,
};
pub use error::McpError;
pub use gateway::{
Expand Down
Loading