From e73933a577609cb1f5ad26a9b4f6722090e55306 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 25 Jun 2026 13:33:10 +0800 Subject: [PATCH 1/2] test(gateway): cover the no-auth handshake + RFC 9728 sub-path discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier no-auth tests stubbed the `/mcp` handler and only checked two discovery endpoints — so they couldn't catch the real failure an editor hit: with auth disabled but discovery still advertising OAuth (the pre-#187 state), VS Code probed `/.well-known/oauth-protected-resource/mcp`, got 200, entered an OAuth flow, and stalled at `initialize`. - auth_disable: assert the RFC 9728 `/.well-known/oauth-protected-resource/mcp` sub-path (the one editors probe first) 404s when auth is disabled and is served when required, alongside the other two endpoints. - gateway_notifications: add `authless_anonymous_client_completes_real_initialize` — boots the real gateway with the REAL `mcp_oauth_middleware` + auth disabled and drives it with a real rmcp client that sends no token, proving the anonymous handshake completes end to end (initialize + list_tools) rather than hanging. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- .../tests/streamable_http/auth_disable.rs | 36 +++++++-- .../streamable_http/gateway_notifications.rs | 74 +++++++++++++++---- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/tests/rust/tests/streamable_http/auth_disable.rs b/tests/rust/tests/streamable_http/auth_disable.rs index b3347c32..62dd8cb5 100644 --- a/tests/rust/tests/streamable_http/auth_disable.rs +++ b/tests/rust/tests/streamable_http/auth_disable.rs @@ -133,6 +133,12 @@ impl Harness { "/.well-known/oauth-protected-resource", get(resource_metadata), ) + // RFC 9728 resource-specific variant — this is the one editors like + // VS Code probe first (`/.well-known/oauth-protected-resource/mcp`). + .route( + "/.well-known/oauth-protected-resource/mcp", + get(resource_metadata), + ) .route( "/.well-known/oauth-authorization-server", get(oauth_metadata), @@ -211,8 +217,12 @@ async fn authless_gateway_does_not_advertise_oauth_discovery() { // without a token. let h = Harness::start(true).await; let client = reqwest::Client::new(); + // Includes the RFC 9728 `/mcp` sub-path — the endpoint VS Code probes first + // (its 200 was what pushed editors into an OAuth flow against an authless + // gateway, leaving them stuck waiting on `initialize`). for path in [ "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-authorization-server", ] { let resp = client @@ -230,12 +240,24 @@ async fn authless_gateway_does_not_advertise_oauth_discovery() { #[tokio::test] async fn auth_required_gateway_advertises_oauth_discovery() { - // The default (auth required) still serves discovery so real OAuth works. + // The default (auth required) still serves discovery so real OAuth works — + // every endpoint, including the RFC 9728 sub-path. let h = Harness::start(false).await; - let resp = reqwest::Client::new() - .get(format!("{}/.well-known/oauth-protected-resource", h.base)) - .send() - .await - .expect("request"); - assert_eq!(resp.status(), reqwest::StatusCode::OK); + let client = reqwest::Client::new(); + for path in [ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-authorization-server", + ] { + let resp = client + .get(format!("{}{path}", h.base)) + .send() + .await + .expect("request"); + assert_eq!( + resp.status(), + reqwest::StatusCode::OK, + "{path} must be served when auth is required" + ); + } } diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 282e6d9b..9a5c9892 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -13,7 +13,7 @@ use axum::{body::Body, http::Request, middleware, middleware::Next, response::Re use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerFeatureRepository, ServerLogManager}; use mcpmux_gateway::{ consumers::MCPNotifier, - mcp::McpMuxGatewayHandler, + mcp::{mcp_oauth_middleware, McpMuxGatewayHandler}, server::{DependenciesBuilder, GatewayState, ServiceContainer}, }; use mcpmux_storage::{InboundClient, InboundClientRepository, RegistrationType}; @@ -90,6 +90,19 @@ impl TestGateway { /// Build a test gateway with an in-memory database and mock repositories. /// The `client_id` and `space_id` are injected into all requests via test middleware. async fn start(client_id: &str, space_id: Uuid) -> Self { + Self::build(client_id, space_id, false).await + } + + /// Like [`start`], but wires the REAL `mcp_oauth_middleware` with inbound + /// auth disabled — no test-injected identity. Proves an anonymous client + /// completes a real `initialize` handshake (the no-auth path that left + /// editors "stuck at initialize" when auth was disabled but discovery still + /// advertised OAuth). + async fn start_authless(space_id: Uuid) -> Self { + Self::build("mcpmux-anonymous", space_id, true).await + } + + async fn build(client_id: &str, space_id: Uuid, authless: bool) -> Self { let ct = CancellationToken::new(); // Create in-memory database @@ -222,19 +235,25 @@ impl TestGateway { http_cfg, ); - // Build router with test OAuth middleware - let test_ctx = Arc::new(TestOAuthContext { - client_id: client_id.to_string(), - space_id, - }); - + // Build the router. Normal tests bypass auth with a test middleware that + // injects a fixed identity; the authless variant exercises the REAL + // middleware with inbound auth disabled, so the gateway must mint an + // anonymous identity itself and the handshake must still succeed. let router = - Router::new() - .nest_service("/mcp", mcp_service) - .layer(middleware::from_fn_with_state( - test_ctx, - test_oauth_middleware, - )); + if authless { + services.gateway_state.write().await.set_auth_disabled(true); + Router::new().nest_service("/mcp", mcp_service).layer( + middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware), + ) + } else { + let test_ctx = Arc::new(TestOAuthContext { + client_id: client_id.to_string(), + space_id, + }); + Router::new().nest_service("/mcp", mcp_service).layer( + middleware::from_fn_with_state(test_ctx, test_oauth_middleware), + ) + }; // Bind to random port let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -407,6 +426,35 @@ async fn test_gateway_advertises_list_changed_capabilities() { gw.shutdown(); } +// ============================================================================ +// B1b: No-auth mode — anonymous client completes a real handshake +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn authless_anonymous_client_completes_real_initialize() { + // Regression for the "stuck at initialize" report: with inbound auth + // disabled, a client that sends NO token must complete the real `initialize` + // handshake through the actual middleware + MCP handler (the gateway mints + // an anonymous identity) instead of stalling. This is the end-to-end check + // the earlier stub-handler test couldn't make. + let space_id = Uuid::new_v4(); + let gw = TestGateway::start_authless(space_id).await; + + // `connect_client` sends no Authorization header and `.serve()` performs the + // initialize handshake — it panics if the gateway 401s or never responds. + let client = connect_client(&gw.url, GatewayTestClient::new()).await; + + // A live session that can list tools proves the handshake fully succeeded. + let tools = client.list_tools(Default::default()).await; + assert!( + tools.is_ok(), + "anonymous client must complete the handshake when auth is disabled" + ); + + client.cancel().await.ok(); + gw.shutdown(); +} + // ============================================================================ // B2: Gateway forwards ToolsChanged to client // ============================================================================ From ef941943eca68b4dd6aa4322c1c1b389a5dcf1d6 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Thu, 25 Jun 2026 14:21:40 +0800 Subject: [PATCH 2/2] test(gateway): full inbound OAuth E2E proves auth-enabled flow is intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a real end-to-end test of the AUTH-REQUIRED path so we can guarantee the "disable auth" feature never silently regresses real OAuth. It drives the actual production handlers over HTTP against a gateway with inbound auth required + a JWT secret configured: DCR register → authorize (consent page, request_id) → consent approve (redirect with code) → token exchange (PKCE S256) → authenticated /mcp handshake returns 200; the same handshake with no token returns 401. To mount the flow in a self-contained harness, expose the OAuth handlers (oauth_register/authorize/token/consent_approve) from the server module, the same way the discovery handlers were already exposed for tests. Together with the disabled-side coverage (anonymous real handshake + list_tools, discovery 404 incl. the RFC 9728 /mcp sub-path), both auth modes are now proven end to end. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik --- Cargo.lock | 3 + crates/mcpmux-gateway/src/server/mod.rs | 11 +- tests/rust/Cargo.toml | 5 + .../tests/streamable_http/auth_oauth_e2e.rs | 346 ++++++++++++++++++ tests/rust/tests/streamable_http/mod.rs | 1 + 5 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 tests/rust/tests/streamable_http/auth_oauth_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index a0df64f7..55c427b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5457,6 +5457,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "base64 0.22.1", "chrono", "dashmap", "futures", @@ -5472,6 +5473,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "sha2", "tempfile", "tokio", "tokio-util", @@ -5480,6 +5482,7 @@ dependencies = [ "url", "uuid", "wiremock", + "zeroize", ] [[package]] diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index f537e4dc..6ad9b3c7 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -13,9 +13,14 @@ mod startup; mod state; // Exposed for integration tests that mount these routes against a real -// ServiceContainer (e.g. asserting the OAuth-discovery endpoints 404 when -// inbound auth is disabled). AppState is also used throughout this module. -pub use handlers::{oauth_metadata, resource_metadata, AppState}; +// ServiceContainer — e.g. asserting the OAuth-discovery endpoints 404 when +// inbound auth is disabled, and driving the full inbound OAuth flow +// (register → authorize → consent → token → authenticated /mcp) end to end. +// AppState is also used throughout this module. +pub use handlers::{ + oauth_authorize, oauth_consent_approve, oauth_metadata, oauth_register, oauth_token, + resource_metadata, AppState, +}; pub use dependencies::{DependenciesBuilder, GatewayDependencies}; pub use handlers::PendingAuthorization; diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml index 4934b434..d77b31ae 100644 --- a/tests/rust/Cargo.toml +++ b/tests/rust/Cargo.toml @@ -46,6 +46,11 @@ reqwest = { version = "0.13", features = ["json"] } # URL parsing for OAuth tests url = "2.5" +# PKCE + JWT-secret material for the inbound-OAuth end-to-end test +base64 = "0.22" +sha2 = "0.10" +zeroize = "1.8" + # Sync primitives for tests parking_lot = "0.12" diff --git a/tests/rust/tests/streamable_http/auth_oauth_e2e.rs b/tests/rust/tests/streamable_http/auth_oauth_e2e.rs new file mode 100644 index 00000000..a77219fb --- /dev/null +++ b/tests/rust/tests/streamable_http/auth_oauth_e2e.rs @@ -0,0 +1,346 @@ +//! Inbound OAuth end-to-end test — proves the AUTH-ENABLED flow is intact. +//! +//! Drives the real production handlers (no stubs) over HTTP against a gateway +//! whose inbound auth is REQUIRED: +//! +//! DCR register → authorize (consent page) → consent approve → token (PKCE +//! S256) → authenticated `/mcp` handshake (200), and a tokenless `/mcp` → +//! 401. +//! +//! This guards the guarantee that toggling/adding the "disable auth" feature +//! did not regress real OAuth: a client that completes the flow gets a token +//! that the `/mcp` middleware accepts, and a client without one is rejected. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + middleware, + routing::{get, post}, + Router, +}; +use base64::Engine; +use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager, SpaceRepository}; +use mcpmux_gateway::{ + consumers::MCPNotifier, + mcp::{mcp_oauth_middleware, McpMuxGatewayHandler}, + server::{ + oauth_authorize, oauth_consent_approve, oauth_register, oauth_token, DependenciesBuilder, + GatewayDependencies, GatewayState, ServiceContainer, + }, +}; +use mcpmux_storage::{InboundClientRepository, SqliteSpaceRepository}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use sha2::Digest; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use tests::db::TestDatabase; +use tests::mocks::*; + +/// A loopback redirect URI (RFC 8252). Uses a real (unused) port so a stray +/// redirect to it is a clean connect error rather than the invalid `:0`. +const REDIRECT: &str = "http://127.0.0.1:8765/callback"; +/// A fixed PKCE verifier (43–128 chars per RFC 7636). +const CODE_VERIFIER: &str = "e2e_pkce_code_verifier_0123456789_abcdefghijklmno"; + +struct Harness { + base: String, + ct: CancellationToken, +} + +impl Drop for Harness { + fn drop(&mut self) { + self.ct.cancel(); + } +} + +impl Harness { + /// Boot a real gateway with inbound auth REQUIRED (no `auth_disabled`), the + /// JWT secret configured, and the public OAuth flow routes mounted next to + /// the `/mcp` service guarded by the real `mcp_oauth_middleware`. + async fn start() -> Self { + let ct = CancellationToken::new(); + + let test_db = TestDatabase::in_memory(); + let database = Arc::new(tokio::sync::Mutex::new(test_db.db)); + + let feature_repo = Arc::new(MockServerFeatureRepository::new()); + let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); + + // Seed a default space so an authenticated client can resolve one. + let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone())); + let space_id = Uuid::new_v4(); + let space = mcpmux_core::domain::Space { + id: space_id, + name: "Test Space".to_string(), + icon: Some("test".to_string()), + description: None, + is_default: true, + sort_order: 0, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + SpaceRepository::create(&*space_repo, &space) + .await + .expect("create space"); + SpaceRepository::set_default(&*space_repo, &space_id) + .await + .expect("set default"); + + let inbound_client_repo = Arc::new(InboundClientRepository::new(database.clone())); + + let deps = DependenciesBuilder::new() + .with_installed_server_repo(Arc::new(MockInstalledServerRepository::new())) + .with_credential_repo(Arc::new(MockCredentialRepository::new())) + .with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new())) + .with_feature_repo(feature_repo as Arc) + .with_feature_set_repo(feature_set_repo as Arc) + .with_server_discovery(Arc::new(ServerDiscoveryService::new( + std::path::PathBuf::from("test-data"), + std::path::PathBuf::from("test-spaces"), + ))) + .with_log_manager(Arc::new(ServerLogManager::new( + mcpmux_core::LogConfig::default(), + ))) + .with_database(database.clone()) + .build() + .expect("build dependencies"); + let deps = GatewayDependencies { + space_repo: space_repo as Arc, + inbound_client_repo, + ..deps + }; + + let (event_tx, _) = broadcast::channel::(256); + + // Auth is REQUIRED here (we never call set_auth_disabled). The JWT secret + // is what the /oauth/token endpoint signs with and the /mcp middleware + // validates against — they must be the same instance, so set it once. + let mut gw_state = GatewayState::new(event_tx.clone()); + gw_state.set_base_url("http://127.0.0.1".to_string()); + gw_state.set_database(database.clone()); + gw_state.set_client_metadata_service(deps.client_metadata_service.clone()); + gw_state.set_jwt_secret(zeroize::Zeroizing::new( + [7u8; mcpmux_storage::JWT_SECRET_SIZE], + )); + let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state)); + + let services = Arc::new(ServiceContainer::initialize( + &deps, + event_tx.clone(), + gateway_state, + )); + + let notifier = Arc::new(MCPNotifier::new( + services.feature_set_resolver.clone(), + services.pool_services.feature_service.clone(), + )); + notifier.clone().start(event_tx.subscribe()); + let handler = McpMuxGatewayHandler::new(services.clone(), notifier.clone()); + + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(Duration::from_secs(15)); + http_cfg.cancellation_token = ct.child_token(); + let mcp_service = StreamableHttpService::new( + move || Ok(handler.clone()), + Arc::new(LocalSessionManager::default()), + http_cfg, + ); + + let mcp_routes = + Router::new() + .nest_service("/mcp", mcp_service) + .layer(middleware::from_fn_with_state( + services.clone(), + mcp_oauth_middleware, + )); + // Public OAuth flow routes, mounted exactly as production does. The + // consent-approve endpoint is the same handler production gates behind + // MCPMUX_E2E_TEST; mounting it directly keeps the test self-contained. + let oauth_routes = Router::new() + .route("/oauth/register", post(oauth_register)) + .route("/oauth/authorize", get(oauth_authorize)) + .route("/oauth/token", post(oauth_token)) + .route("/oauth/consent/approve", post(oauth_consent_approve)) + .with_state(services.gateway_state.clone()); + let router = mcp_routes.merge(oauth_routes); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().unwrap().port(); + let ct_clone = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct_clone.cancelled().await }) + .await + .unwrap(); + }); + tokio::time::sleep(Duration::from_millis(50)).await; + + Self { + base: format!("http://127.0.0.1:{port}"), + ct, + } + } +} + +/// S256 PKCE challenge for [`CODE_VERIFIER`]. +fn code_challenge() -> String { + let mut hasher = sha2::Sha256::new(); + hasher.update(CODE_VERIFIER.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize()) +} + +/// Slice out the value following `start`, up to any of `ends` (or end of string). +fn between(s: &str, start: &str, ends: &[char]) -> Option { + let i = s.find(start)? + start.len(); + let rest = &s[i..]; + let j = rest.find(|c| ends.contains(&c)).unwrap_or(rest.len()); + Some(rest[..j].to_string()) +} + +/// Pull a single query-param value out of a URL. +fn query_param(url: &str, key: &str) -> Option { + let q = url.split('?').nth(1)?; + for pair in q.split('&') { + let mut it = pair.splitn(2, '='); + if it.next()? == key { + return Some(it.next().unwrap_or("").to_string()); + } + } + None +} + +const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"e2e","version":"1.0"}}}"#; + +#[tokio::test(flavor = "multi_thread")] +async fn auth_enabled_full_oauth_flow_then_authenticated_mcp() { + let h = Harness::start().await; + // Don't auto-follow redirects: the OAuth steps return their own responses + // (consent HTML, JSON), and a stray follow to the client redirect_uri would + // mask the real status. + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + // 1. Dynamic client registration. + let reg: serde_json::Value = http + .post(format!("{}/oauth/register", h.base)) + .json(&serde_json::json!({ + "client_name": "e2e", + "redirect_uris": [REDIRECT], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + })) + .send() + .await + .expect("register request") + .json() + .await + .expect("register json"); + let client_id = reg["client_id"].as_str().expect("client_id").to_string(); + + // 2. Authorization request → branded consent page carrying the request_id. + // Build the query manually (redirect_uri needs percent-encoding; the S256 + // challenge is already URL-safe base64). + let challenge = code_challenge(); + let redirect_enc: String = url::form_urlencoded::byte_serialize(REDIRECT.as_bytes()).collect(); + let authorize_url = format!( + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope=mcp&state=st-123&code_challenge={}&code_challenge_method=S256", + h.base, client_id, redirect_enc, challenge, + ); + let authorize = http + .get(&authorize_url) + .send() + .await + .expect("authorize request"); + let status = authorize.status(); + let location = authorize + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let html = authorize.text().await.unwrap(); + assert_eq!( + status, + reqwest::StatusCode::OK, + "authorize should render the consent page; got {status}, location={location:?}, body={}", + &html.chars().take(300).collect::() + ); + let request_id = + between(&html, "request_id=", &['"', '&', ' ', '\'']).expect("request_id in consent HTML"); + + // 3. Approve consent → redirect URL with the authorization code. + let approve: serde_json::Value = http + .post(format!("{}/oauth/consent/approve", h.base)) + .json(&serde_json::json!({ "request_id": request_id, "approved": true })) + .send() + .await + .expect("approve request") + .json() + .await + .expect("approve json"); + let redirect_url = approve["redirect_url"].as_str().expect("redirect_url"); + let code = query_param(redirect_url, "code").expect("authorization code"); + + // 4. Token exchange with the PKCE verifier. + let token: serde_json::Value = http + .post(format!("{}/oauth/token", h.base)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("redirect_uri", REDIRECT), + ("client_id", client_id.as_str()), + ("code_verifier", CODE_VERIFIER), + ]) + .send() + .await + .expect("token request") + .json() + .await + .expect("token json"); + let access_token = token["access_token"] + .as_str() + .expect("access_token in token response") + .to_string(); + + // 5. Authenticated MCP handshake — the minted token must be accepted. + let authed = http + .post(format!("{}/mcp", h.base)) + .header("authorization", format!("Bearer {access_token}")) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .expect("authed mcp request"); + assert_eq!( + authed.status(), + reqwest::StatusCode::OK, + "a valid OAuth token must authenticate the MCP handshake" + ); + + // 6. The same handshake without a token is rejected (auth IS required). + let denied = http + .post(format!("{}/mcp", h.base)) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .expect("tokenless mcp request"); + assert_eq!( + denied.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a tokenless request must 401 when auth is enabled" + ); +} diff --git a/tests/rust/tests/streamable_http/mod.rs b/tests/rust/tests/streamable_http/mod.rs index ced1d6a6..f470130d 100644 --- a/tests/rust/tests/streamable_http/mod.rs +++ b/tests/rust/tests/streamable_http/mod.rs @@ -6,5 +6,6 @@ //! - Proper protocol negotiation mod auth_disable; +mod auth_oauth_e2e; mod gateway_notifications; mod notifications;