Skip to content

Commit c644426

Browse files
hashemixSVilgelm
andauthored
perf(auth): reuse a shared reqwest::Client with timeouts (#172)
* perf(auth): reuse a shared reqwest::Client OAuth discovery, metadata, and JWKS/introspection/userinfo fetches each built a fresh Client (new pool + TLS config) per call. Share one process-wide client so requests reuse the connection pool, which matters under concurrent key-rotation verifications. Assisted-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> * Configure shared HTTP client and add tests Set connect_timeout to 10s and timeout to 30s and set a crate user-agent. Fall back to reqwest::Client::new() on build error and emit a tracing::warn. Add tests to verify the client is clonable and can build requests without performing network calls. --------- Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> Co-authored-by: Sergey Vilgelm <sergey@vilgelm.com>
1 parent f4edef4 commit c644426

5 files changed

Lines changed: 71 additions & 11 deletions

File tree

crates/rust-mcp-extra/src/token_verifier/generic_token_verifier.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use async_trait::async_trait;
44
use reqwest::{header::AUTHORIZATION, StatusCode};
55
use rust_mcp_sdk::{
66
auth::{
7-
decode_token_header, default_jwks_algorithms, Algorithm, Audience, AuthInfo,
8-
AuthenticationError, IntrospectionResponse, JsonWebKeySet, OauthTokenVerifier,
7+
decode_token_header, default_jwks_algorithms, shared_http_client, Algorithm, Audience,
8+
AuthInfo, AuthenticationError, IntrospectionResponse, JsonWebKeySet, OauthTokenVerifier,
99
},
1010
mcp_http::error_message_from_response,
1111
};
@@ -224,7 +224,7 @@ impl GenericOauthTokenVerifier {
224224
}
225225
};
226226

227-
let client = reqwest::Client::new();
227+
let client = shared_http_client();
228228

229229
let response = client
230230
.get(user_info_endpoint.to_owned())
@@ -267,7 +267,7 @@ impl GenericOauthTokenVerifier {
267267
token: &str,
268268
introspection_endpoint: &Url,
269269
) -> Result<AuthInfo, AuthenticationError> {
270-
let client = reqwest::Client::new();
270+
let client = shared_http_client();
271271

272272
// Form data body
273273
let mut form = HashMap::new();
@@ -355,7 +355,9 @@ impl GenericOauthTokenVerifier {
355355
}
356356

357357
async fn populate_jwks(&self, jwks_uri: &Url) -> Result<(), AuthenticationError> {
358-
let response = reqwest::get(jwks_uri.to_owned())
358+
let response = shared_http_client()
359+
.get(jwks_uri.to_owned())
360+
.send()
359361
.await
360362
.map_err(|err| AuthenticationError::Jwks(err.to_string()))?;
361363
let jwks: JsonWebKeySet = response

crates/rust-mcp-sdk/src/auth.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,64 @@ pub use spec::Audience;
2222
pub use spec::*;
2323
#[cfg(feature = "auth")]
2424
pub use token_verifier::*;
25+
26+
#[cfg(feature = "auth")]
27+
use std::sync::LazyLock;
28+
#[cfg(feature = "auth")]
29+
use std::time::Duration;
30+
31+
/// Process-wide shared `reqwest::Client` used for OAuth discovery, metadata, and
32+
/// JWKS fetches.
33+
///
34+
/// Constructing a new `Client` per call creates a fresh connection pool and TLS
35+
/// configuration on every request. Cloning this shared client (cheap; it is
36+
/// `Arc`-backed) reuses the same pool, which matters during key-rotation events
37+
/// where many verifications fetch JWKS concurrently.
38+
///
39+
/// Configured with a `connect_timeout` of 10 seconds and a total `timeout` of
40+
/// 30 seconds to prevent hanging on unresponsive OAuth endpoints.
41+
#[cfg(feature = "auth")]
42+
static SHARED_HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
43+
reqwest::Client::builder()
44+
.connect_timeout(Duration::from_secs(10))
45+
.timeout(Duration::from_secs(30))
46+
.user_agent(concat!("rust-mcp-sdk/", env!("CARGO_PKG_VERSION")))
47+
.build()
48+
.unwrap_or_else(|err| {
49+
tracing::warn!(
50+
?err,
51+
"failed to build configured shared HTTP client, falling back to default"
52+
);
53+
reqwest::Client::new()
54+
})
55+
});
56+
57+
/// Returns a clone of the process-wide shared [`reqwest::Client`].
58+
///
59+
/// The returned client reuses the same connection pool and TLS sessions.
60+
/// Two consecutive calls return independent clones of the same underlying
61+
/// client.
62+
#[cfg(feature = "auth")]
63+
pub fn shared_http_client() -> reqwest::Client {
64+
SHARED_HTTP_CLIENT.clone()
65+
}
66+
67+
#[cfg(all(test, feature = "auth"))]
68+
mod shared_client_tests {
69+
use super::*;
70+
71+
#[test]
72+
fn shared_http_client_is_clonable() {
73+
let c1 = shared_http_client();
74+
let c2 = shared_http_client();
75+
// Both clones point to the same inner connection pool
76+
assert_eq!(std::mem::size_of_val(&c1), std::mem::size_of_val(&c2));
77+
}
78+
79+
#[test]
80+
fn shared_http_client_accepts_url() {
81+
let client = shared_http_client();
82+
// Verify the client can build a request (no network call)
83+
let _req = client.get("https://example.com").build().unwrap();
84+
}
85+
}

crates/rust-mcp-sdk/src/auth/auth_provider/remote_auth_provider.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use async_trait::async_trait;
1313
use bytes::Bytes;
1414
use http::{header::CONTENT_TYPE, StatusCode};
1515
use http_body_util::{BodyExt, Full};
16-
use reqwest::Client;
1716
use std::{collections::HashMap, sync::Arc};
1817

1918
/// Represents a **Remote OAuth authentication provider** integrated with the MCP server.
@@ -68,7 +67,7 @@ impl RemoteAuthProvider {
6867
token_verifier: Box<dyn OauthTokenVerifier>,
6968
required_scopes: Option<Vec<String>>,
7069
) -> Result<Self, reqwest::Error> {
71-
let client = Client::new();
70+
let client = crate::auth::shared_http_client();
7271

7372
let auth_server_meta = client
7473
.get(authorization_server_metadata_url)

crates/rust-mcp-sdk/src/auth/metadata.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use crate::{
55
error::McpSdkError,
66
utils::join_url,
77
};
8-
use reqwest::Client;
98
use serde::{Deserialize, Serialize};
109
use serde_json::Value;
1110
use thiserror::Error;
@@ -139,7 +138,7 @@ impl<'a> AuthMetadataBuilder<'a> {
139138
where
140139
S: Into<Cow<'a, str>>,
141140
{
142-
let client = Client::new();
141+
let client = crate::auth::shared_http_client();
143142
let json: Value = client
144143
.get(discovery_url)
145144
.send()

crates/rust-mcp-sdk/src/auth/spec/discovery.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use crate::{
33
error::McpSdkError,
44
mcp_http::url_base,
55
};
6-
use reqwest::Client;
76
use serde::{Deserialize, Serialize};
87
use std::collections::HashMap;
98
use url::Url;
@@ -151,7 +150,7 @@ impl AuthorizationServerMetadata {
151150
/// to RFC 8414 (OAuth 2.0 Authorization Server Metadata) or OpenID Connect Discovery 1.0.
152151
///
153152
pub async fn from_discovery_url(discovery_url: &str) -> Result<Self, McpSdkError> {
154-
let client = Client::new();
153+
let client = crate::auth::shared_http_client();
155154
let metadata = client
156155
.get(discovery_url)
157156
.send()

0 commit comments

Comments
 (0)