fix: pass client conformance suite (#960)

* fix(auth): support oauth metadata fallbacks

* ci: run client conformance scenarios

* fix: pass full client conformance suite

* ci: run full client conformance suite

* fix: update SSE stream constructor
This commit is contained in:
Dale Seo 2026-07-08 11:25:12 -04:00 committed by GitHub
parent dbda50c0eb
commit a03793530d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 512 additions and 30 deletions

View file

@ -29,8 +29,7 @@ jobs:
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# Build the whole package (server + client bins): the conformance crate is # Build the whole package (server + client bins): the conformance crate is
# excluded from the workspace default-members, so this is the only CI job # excluded from the workspace default-members.
# that catches compile breakage in it.
- name: Build conformance binaries - name: Build conformance binaries
run: cargo build -p mcp-conformance run: cargo build -p mcp-conformance
@ -75,3 +74,33 @@ jobs:
with: with:
name: conformance-server-results name: conformance-server-results
path: conformance-results path: conformance-results
client:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build conformance binaries
run: cargo build -p mcp-conformance
- name: Run full client conformance suite
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "$(pwd)/target/debug/conformance-client" \
--suite all \
--spec-version 2025-11-25 \
-o conformance-client-results/full
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: conformance-client-results
path: conformance-client-results

View file

@ -180,6 +180,7 @@ impl ClientHandler for FullClientHandler {
const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json"; const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json";
const REDIRECT_URI: &str = "http://localhost:3000/callback"; const REDIRECT_URI: &str = "http://localhost:3000/callback";
const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"];
/// Perform the headless OAuth authorization-code flow. /// Perform the headless OAuth authorization-code flow.
/// ///
@ -365,13 +366,10 @@ async fn run_auth_scope_step_up_client(
// Drop old client, re-auth with upgraded scopes // Drop old client, re-auth with upgraded scopes
client.cancel().await.ok(); client.cancel().await.ok();
// Re-do the full flow; the server will give us the right scopes
// on the second authorization request.
let mut oauth2 = OAuthState::new(server_url, None).await?; let mut oauth2 = OAuthState::new(server_url, None).await?;
// Pass the escalated scope hint
oauth2 oauth2
.start_authorization_with_metadata_url( .start_authorization_with_metadata_url(
&[], SCOPE_STEP_UP_ESCALATED_SCOPES,
REDIRECT_URI, REDIRECT_URI,
Some("conformance-client"), Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL), Some(CIMD_CLIENT_METADATA_URL),
@ -387,7 +385,9 @@ async fn run_auth_scope_step_up_client(
) )
.await?; .await?;
let am2 = oauth2.into_authorization_manager().unwrap(); let am2 = oauth2.into_authorization_manager().ok_or_else(|| {
anyhow::anyhow!("Missing authorization manager after step-up")
})?;
let auth_client2 = AuthClient::new(reqwest::Client::default(), am2); let auth_client2 = AuthClient::new(reqwest::Client::default(), am2);
let transport2 = StreamableHttpClientTransport::with_client( let transport2 = StreamableHttpClientTransport::with_client(
auth_client2, auth_client2,
@ -435,7 +435,9 @@ async fn run_auth_scope_retry_limit_client(
) )
.await?; .await?;
let am = oauth.into_authorization_manager().unwrap(); let am = oauth
.into_authorization_manager()
.ok_or_else(|| anyhow::anyhow!("Missing authorization manager"))?;
let auth_client = AuthClient::new(reqwest::Client::default(), am); let auth_client = AuthClient::new(reqwest::Client::default(), am);
let transport = StreamableHttpClientTransport::with_client( let transport = StreamableHttpClientTransport::with_client(
auth_client, auth_client,
@ -443,7 +445,18 @@ async fn run_auth_scope_retry_limit_client(
); );
let client = BasicClientHandler.serve(transport).await?; let client = BasicClientHandler.serve(transport).await?;
let tools = client.list_tools(Default::default()).await?; let tools = match client.list_tools(Default::default()).await {
Ok(tools) => tools,
Err(err) => {
tracing::info!(
"Scope retry limit scenario stopped after authorization attempt {}: {}",
attempt + 1,
err
);
client.cancel().await.ok();
return Ok(());
}
};
let mut got_403 = false; let mut got_403 = false;
for tool in &tools.tools { for tool in &tools.tools {
@ -467,7 +480,7 @@ async fn run_auth_scope_retry_limit_client(
attempt += 1; attempt += 1;
if attempt >= max_retries { if attempt >= max_retries {
tracing::info!("Reached retry limit ({max_retries}), giving up"); tracing::info!("Reached retry limit ({max_retries}), giving up");
return Err(anyhow::anyhow!("Scope retry limit reached")); return Ok(());
} }
} }
Ok(()) Ok(())

View file

@ -30,6 +30,12 @@ use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION;
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10; const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10;
const RESOURCE_METADATA_POST_PROBE_BODY: &str = concat!(
r#"{"jsonrpc":"2.0","id":"auth-discovery","method":"initialize","params":{"#,
r#""protocolVersion":"2024-11-05","capabilities":{},"#,
r#""clientInfo":{"name":"rmcp-auth-discovery","version":"0.0.0"}}"#,
r#"}"#
);
const CLOUD_METADATA_HOSTS: &[&str] = &[ const CLOUD_METADATA_HOSTS: &[&str] = &[
"metadata", "metadata",
"metadata.google.internal", "metadata.google.internal",
@ -894,11 +900,31 @@ impl AuthorizationManager {
} }
} }
fn is_allowed_authorization_server_metadata_url(url: &Url) -> bool { fn is_loopback_metadata_host(host: &str) -> bool {
Self::is_http_url(url) let host = host.trim_end_matches('.').to_ascii_lowercase();
&& url host == "localhost"
.host_str() || host.ends_with(".localhost")
.is_some_and(|host| !Self::is_disallowed_metadata_host(host)) || matches!(host.parse::<IpAddr>(), Ok(IpAddr::V4(addr)) if addr.is_loopback())
|| matches!(host.parse::<IpAddr>(), Ok(IpAddr::V6(addr)) if addr.is_loopback())
}
fn is_allowed_authorization_server_metadata_url(base_url: &Url, url: &Url) -> bool {
if !Self::is_http_url(url) {
return false;
}
let Some(host) = url.host_str() else {
return false;
};
if !Self::is_disallowed_metadata_host(host) {
return true;
}
base_url
.host_str()
.is_some_and(Self::is_loopback_metadata_host)
&& Self::is_loopback_metadata_host(host)
} }
fn resolve_resource_metadata_url(value: &str, base_url: &Url) -> Option<Url> { fn resolve_resource_metadata_url(value: &str, base_url: &Url) -> Option<Url> {
@ -1077,9 +1103,25 @@ impl AuthorizationManager {
return Ok(metadata); return Ok(metadata);
} }
// No valid authorization metadata found - return error instead of guessing debug!("falling back to legacy OAuth endpoints derived from the base URL");
// OAuth endpoints must be discovered from the server, not constructed by the client Ok(Self::legacy_authorization_metadata(&self.base_url))
Err(AuthError::NoAuthorizationSupport) }
fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata {
let endpoint = |path: &str| {
let mut url = base_url.clone();
url.set_query(None);
url.set_fragment(None);
url.set_path(path);
url.to_string()
};
AuthorizationMetadata {
authorization_endpoint: endpoint("/authorize"),
token_endpoint: endpoint("/token"),
registration_endpoint: Some(endpoint("/register")),
..Default::default()
}
} }
/// get client id and credentials /// get client id and credentials
@ -1891,7 +1933,7 @@ impl AuthorizationManager {
}, },
}; };
if !Self::is_allowed_authorization_server_metadata_url(&candidate_url) { if !Self::is_allowed_authorization_server_metadata_url(&self.base_url, &candidate_url) {
warn!("rejecting authorization server metadata URL `{candidate_url}`"); warn!("rejecting authorization server metadata URL `{candidate_url}`");
continue; continue;
} }
@ -1937,6 +1979,7 @@ impl AuthorizationManager {
&& actual == expected.trim_end_matches('/')) && actual == expected.trim_end_matches('/'))
|| (Self::is_root_resource_identifier(actual) || (Self::is_root_resource_identifier(actual)
&& expected == actual.trim_end_matches('/')) && expected == actual.trim_end_matches('/'))
|| Self::root_resource_identifier_covers_path(actual, expected)
} }
fn is_root_resource_identifier(value: &str) -> bool { fn is_root_resource_identifier(value: &str) -> bool {
@ -1944,9 +1987,24 @@ impl AuthorizationManager {
.is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none()) .is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none())
} }
fn root_resource_identifier_covers_path(root_resource: &str, path_resource: &str) -> bool {
let Ok(root_resource) = Url::parse(root_resource) else {
return false;
};
let Ok(path_resource) = Url::parse(path_resource) else {
return false;
};
root_resource.path() == "/"
&& root_resource.query().is_none()
&& root_resource.fragment().is_none()
&& path_resource.path() != "/"
&& Self::is_same_origin(&root_resource, &path_resource)
}
async fn discover_resource_metadata_url(&self) -> Result<Option<Url>, AuthError> { async fn discover_resource_metadata_url(&self) -> Result<Option<Url>, AuthError> {
if let Ok(Some(resource_metadata_url)) = if let Ok(Some(resource_metadata_url)) =
self.fetch_resource_metadata_url(&self.base_url).await self.fetch_resource_metadata_url(&self.base_url, true).await
{ {
return Ok(Some(resource_metadata_url)); return Ok(Some(resource_metadata_url));
} }
@ -1960,8 +2018,9 @@ impl AuthorizationManager {
discovery_url.set_query(None); discovery_url.set_query(None);
discovery_url.set_fragment(None); discovery_url.set_fragment(None);
discovery_url.set_path(&candidate_path); discovery_url.set_path(&candidate_path);
if let Ok(Some(resource_metadata_url)) = if let Ok(Some(resource_metadata_url)) = self
self.fetch_resource_metadata_url(&discovery_url).await .fetch_resource_metadata_url(&discovery_url, false)
.await
{ {
return Ok(Some(resource_metadata_url)); return Ok(Some(resource_metadata_url));
} }
@ -1972,7 +2031,11 @@ impl AuthorizationManager {
/// Extract the resource metadata url from the WWW-Authenticate header value. /// Extract the resource metadata url from the WWW-Authenticate header value.
/// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for
async fn fetch_resource_metadata_url(&self, url: &Url) -> Result<Option<Url>, AuthError> { async fn fetch_resource_metadata_url(
&self,
url: &Url,
allow_post_probe: bool,
) -> Result<Option<Url>, AuthError> {
let response = match self.discovery_get(url).await { let response = match self.discovery_get(url).await {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
@ -1981,16 +2044,64 @@ impl AuthorizationManager {
} }
}; };
if response.status() == StatusCode::OK { match response.status() {
return Ok(Some(url.clone())); StatusCode::OK => Ok(Some(url.clone())),
} else if response.status() != StatusCode::UNAUTHORIZED { StatusCode::UNAUTHORIZED => Ok(self
.extract_resource_metadata_url_from_www_authenticate(&response)
.await),
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED if allow_post_probe => {
self.fetch_resource_metadata_url_with_post_probe(url).await
}
status => {
debug!("resource metadata probe returned unexpected status: {status}");
Ok(None)
}
}
}
async fn fetch_resource_metadata_url_with_post_probe(
&self,
url: &Url,
) -> Result<Option<Url>, AuthError> {
let request = oauth2::http::Request::builder()
.method("POST")
.uri(url.as_str())
.header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05")
.header(CONTENT_TYPE, "application/json")
.body(RESOURCE_METADATA_POST_PROBE_BODY.as_bytes().to_vec())
.map_err(|error| AuthError::InternalError(error.to_string()))?;
let response = match self
.http_client
.execute(OAuthHttpRequest::new(
request,
OAuthHttpRedirectPolicy::Stop,
))
.await
{
Ok(response) => response,
Err(error) => {
debug!("resource metadata POST probe failed: {}", error);
return Ok(None);
}
};
if response.status() != StatusCode::UNAUTHORIZED {
debug!( debug!(
"resource metadata probe returned unexpected status: {}", "resource metadata POST probe returned unexpected status: {}",
response.status() response.status()
); );
return Ok(None); return Ok(None);
} }
Ok(self
.extract_resource_metadata_url_from_www_authenticate(&response)
.await)
}
async fn extract_resource_metadata_url_from_www_authenticate(
&self,
response: &HttpResponse,
) -> Option<Url> {
let mut parsed_url = None; let mut parsed_url = None;
for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { for value in response.headers().get_all(WWW_AUTHENTICATE).iter() {
let Ok(value_str) = value.to_str() else { let Ok(value_str) = value.to_str() else {
@ -2009,7 +2120,7 @@ impl AuthorizationManager {
} }
} }
Ok(parsed_url) parsed_url
} }
async fn fetch_resource_metadata_from_url( async fn fetch_resource_metadata_from_url(
@ -3227,6 +3338,13 @@ mod tests {
.unwrap() .unwrap()
} }
fn empty_response(status: u16) -> HttpResponse {
oauth2::http::Response::builder()
.status(status)
.body(Vec::new())
.unwrap()
}
#[tokio::test] #[tokio::test]
async fn custom_http_client_handles_protected_resource_discovery() { async fn custom_http_client_handles_protected_resource_discovery() {
let challenge = oauth2::http::Response::builder() let challenge = oauth2::http::Response::builder()
@ -3290,6 +3408,181 @@ mod tests {
); );
} }
#[tokio::test]
async fn protected_resource_metadata_supports_authorization_server_path_insertion() {
let client = RecordingOAuthHttpClient::with_responses(vec![
empty_response(401),
http_response(
200,
serde_json::json!({
"resource": "https://mcp.example.com/",
"authorization_servers": ["https://auth.example.com/tenant1"]
}),
),
http_response(
200,
serde_json::json!({
"resource": "https://mcp.example.com/",
"authorization_servers": ["https://auth.example.com/tenant1"]
}),
),
http_response(
200,
serde_json::json!({
"issuer": "https://auth.example.com/tenant1",
"authorization_endpoint": "https://auth.example.com/tenant1/authorize",
"token_endpoint": "https://auth.example.com/tenant1/token"
}),
),
]);
let manager = AuthorizationManager::new_with_oauth_http_client(
"https://mcp.example.com/",
Arc::new(client.clone()),
)
.await
.unwrap();
let metadata = manager.discover_metadata().await.unwrap();
assert_eq!(
(
metadata.issuer.as_deref(),
metadata.authorization_endpoint.as_str(),
client
.requests()
.iter()
.map(|request| request.uri.as_str())
.collect::<Vec<_>>(),
),
(
Some("https://auth.example.com/tenant1"),
"https://auth.example.com/tenant1/authorize",
vec![
"https://mcp.example.com/",
"https://mcp.example.com/.well-known/oauth-protected-resource",
"https://mcp.example.com/.well-known/oauth-protected-resource",
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
],
)
);
}
#[tokio::test]
async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() {
let challenge = oauth2::http::Response::builder()
.status(401)
.header(
"www-authenticate",
r#"Bearer resource_metadata="/custom/metadata/location.json""#,
)
.body(Vec::new())
.unwrap();
let client = RecordingOAuthHttpClient::with_responses(vec![
empty_response(404),
challenge,
http_response(
200,
serde_json::json!({
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com/tenant1"]
}),
),
empty_response(404),
empty_response(404),
http_response(
200,
serde_json::json!({
"issuer": "https://auth.example.com/tenant1",
"authorization_endpoint": "https://auth.example.com/tenant1/authorize",
"token_endpoint": "https://auth.example.com/tenant1/token"
}),
),
]);
let manager = AuthorizationManager::new_with_oauth_http_client(
"https://mcp.example.com/mcp",
Arc::new(client.clone()),
)
.await
.unwrap();
let metadata = manager.discover_metadata().await.unwrap();
assert_eq!(
(
metadata.token_endpoint.as_str(),
client
.requests()
.iter()
.map(|request| request.uri.as_str())
.collect::<Vec<_>>(),
),
(
"https://auth.example.com/tenant1/token",
vec![
"https://mcp.example.com/mcp",
"https://mcp.example.com/mcp",
"https://mcp.example.com/custom/metadata/location.json",
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
"https://auth.example.com/.well-known/openid-configuration/tenant1",
"https://auth.example.com/tenant1/.well-known/openid-configuration",
],
)
);
assert_eq!(
client
.requests()
.iter()
.take(2)
.map(|request| request.method.as_str())
.collect::<Vec<_>>(),
vec!["GET", "POST"]
);
}
#[tokio::test]
async fn discover_metadata_falls_back_to_legacy_default_endpoints() {
let client = RecordingOAuthHttpClient::with_responses(vec![
empty_response(404),
empty_response(404),
empty_response(404),
empty_response(404),
empty_response(404),
]);
let manager = AuthorizationManager::new_with_oauth_http_client(
"https://legacy.example.com/",
Arc::new(client.clone()),
)
.await
.unwrap();
let metadata = manager.discover_metadata().await.unwrap();
assert_eq!(
(
metadata.authorization_endpoint.as_str(),
metadata.token_endpoint.as_str(),
metadata.registration_endpoint.as_deref(),
client
.requests()
.iter()
.map(|request| request.uri.as_str())
.collect::<Vec<_>>(),
),
(
"https://legacy.example.com/authorize",
"https://legacy.example.com/token",
Some("https://legacy.example.com/register"),
vec![
"https://legacy.example.com/",
"https://legacy.example.com/",
"https://legacy.example.com/.well-known/oauth-protected-resource",
"https://legacy.example.com/.well-known/oauth-authorization-server",
"https://legacy.example.com/.well-known/openid-configuration",
],
)
);
}
#[tokio::test] #[tokio::test]
async fn discovery_get_follows_same_origin_redirects() { async fn discovery_get_follows_same_origin_redirects() {
let client = RecordingOAuthHttpClient::with_responses(vec![ let client = RecordingOAuthHttpClient::with_responses(vec![
@ -3371,6 +3664,7 @@ mod tests {
"resource": "https://mcp.example.com/mcp", "resource": "https://mcp.example.com/mcp",
"authorization_servers": [ "authorization_servers": [
"http://169.254.169.254/latest/meta-data/", "http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:8080/tenant1",
"https://auth.example.com" "https://auth.example.com"
] ]
}), }),
@ -3412,6 +3706,63 @@ mod tests {
); );
} }
#[tokio::test]
async fn allows_loopback_authorization_server_when_resource_is_loopback() {
let challenge = oauth2::http::Response::builder()
.status(401)
.header(
"www-authenticate",
r#"Bearer resource_metadata="http://localhost/custom-metadata.json""#,
)
.body(Vec::new())
.unwrap();
let client = RecordingOAuthHttpClient::with_responses(vec![
challenge,
http_response(
200,
serde_json::json!({
"resource": "http://localhost/mcp",
"authorization_servers": ["http://127.0.0.1:8080/tenant1"]
}),
),
http_response(
200,
serde_json::json!({
"issuer": "http://127.0.0.1:8080/tenant1",
"authorization_endpoint": "http://127.0.0.1:8080/tenant1/authorize",
"token_endpoint": "http://127.0.0.1:8080/tenant1/token"
}),
),
]);
let manager = AuthorizationManager::new_with_oauth_http_client(
"http://localhost/mcp",
Arc::new(client.clone()),
)
.await
.unwrap();
let metadata = manager.discover_metadata().await.unwrap();
assert_eq!(
(
metadata.issuer.as_deref(),
client
.requests()
.iter()
.map(|request| request.uri.as_str())
.collect::<Vec<_>>(),
),
(
Some("http://127.0.0.1:8080/tenant1"),
vec![
"http://localhost/mcp",
"http://localhost/custom-metadata.json",
"http://127.0.0.1:8080/.well-known/oauth-authorization-server/tenant1",
],
)
);
}
#[tokio::test] #[tokio::test]
async fn protected_resource_discovery_rejects_mismatched_resource() { async fn protected_resource_discovery_rejects_mismatched_resource() {
let challenge = oauth2::http::Response::builder() let challenge = oauth2::http::Response::builder()
@ -3493,6 +3844,10 @@ mod tests {
"https://mcp.example.com", "https://mcp.example.com",
"https://mcp.example.com/" "https://mcp.example.com/"
)); ));
assert!(AuthorizationManager::resource_identifiers_match(
"https://mcp.example.com/mcp",
"https://mcp.example.com"
));
assert!(!AuthorizationManager::resource_identifiers_match( assert!(!AuthorizationManager::resource_identifiers_match(
"https://mcp.example.com/mcp", "https://mcp.example.com/mcp",
@ -3502,6 +3857,14 @@ mod tests {
"https://mcp.example.com/mcp", "https://mcp.example.com/mcp",
"https://real.example.com/mcp" "https://real.example.com/mcp"
)); ));
assert!(!AuthorizationManager::resource_identifiers_match(
"https://mcp.example.com/mcp",
"https://real.example.com"
));
assert!(!AuthorizationManager::resource_identifiers_match(
"https://mcp.example.com/mcp",
"https://mcp.example.com?resource=mcp"
));
} }
#[tokio::test] #[tokio::test]

View file

@ -407,6 +407,65 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
}) })
} }
/// Convert an SSE stream into JSON-RPC messages with reconnect semantics.
///
/// This is used for request-scoped SSE responses as well as the standalone
/// GET stream. A request-scoped stream can close before its response arrives,
/// and SEP-1699 requires the client to honor `retry` and resume with
/// `Last-Event-ID` in that case.
fn reconnecting_sse_to_jsonrpc(
stream: BoxedSseStream,
client: C,
session_id: Arc<str>,
uri: Arc<str>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
retry_config: Arc<dyn SseRetryPolicy>,
) -> impl Stream<Item = Result<ServerJsonRpcMessage, StreamableHttpError<C::Error>>> + Send + 'static
{
SseAutoReconnectStream::new(
stream,
StreamableHttpClientReconnect {
client,
session_id,
uri,
auth_header,
custom_headers,
},
retry_config,
)
}
/// Convert a POST response SSE stream into JSON-RPC messages.
///
/// Stateful sessions can resume via GET when the response stream closes
/// before the server sends the matching JSON-RPC response. Stateless
/// transports do not have enough state to resume, so they keep the raw
/// SSE-to-JSON-RPC mapping.
fn response_sse_to_jsonrpc(
stream: BoxedSseStream,
session_id: Option<Arc<str>>,
client: C,
uri: Arc<str>,
auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>,
retry_config: Arc<dyn SseRetryPolicy>,
) -> BoxStream<'static, Result<ServerJsonRpcMessage, StreamableHttpError<C::Error>>> {
match session_id {
Some(session_id) => Self::reconnecting_sse_to_jsonrpc(
stream,
client,
session_id,
uri,
auth_header,
custom_headers,
retry_config,
)
.boxed(),
None => Self::raw_sse_to_jsonrpc(stream).boxed(),
}
}
async fn execute_sse_stream( async fn execute_sse_stream(
sse_stream: impl Stream<Item = Result<ServerJsonRpcMessage, StreamableHttpError<C::Error>>> sse_stream: impl Stream<Item = Result<ServerJsonRpcMessage, StreamableHttpError<C::Error>>>
+ Send + Send
@ -880,8 +939,17 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
&mut pending_stream_response_ids, &mut pending_stream_response_ids,
request_id, request_id,
); );
let sse_stream = Self::response_sse_to_jsonrpc(
stream,
session_id.clone(),
self.client.clone(),
config.uri.clone(),
config.auth_header.clone(),
protocol_headers.clone(),
self.config.retry_config.clone(),
);
streams.spawn(Self::execute_sse_stream( streams.spawn(Self::execute_sse_stream(
Self::raw_sse_to_jsonrpc(stream), sse_stream,
sse_worker_tx.clone(), sse_worker_tx.clone(),
true, true,
transport_task_ct.child_token(), transport_task_ct.child_token(),
@ -913,8 +981,17 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
&mut pending_stream_response_ids, &mut pending_stream_response_ids,
request_id, request_id,
); );
let sse_stream = Self::response_sse_to_jsonrpc(
stream,
session_id.clone(),
self.client.clone(),
config.uri.clone(),
config.auth_header.clone(),
protocol_headers.clone(),
self.config.retry_config.clone(),
);
streams.spawn(Self::execute_sse_stream( streams.spawn(Self::execute_sse_stream(
Self::raw_sse_to_jsonrpc(stream), sse_stream,
sse_worker_tx.clone(), sse_worker_tx.clone(),
true, true,
transport_task_ct.child_token(), transport_task_ct.child_token(),