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:
parent
dbda50c0eb
commit
a03793530d
4 changed files with 512 additions and 30 deletions
33
.github/workflows/conformance.yml
vendored
33
.github/workflows/conformance.yml
vendored
|
|
@ -29,8 +29,7 @@ jobs:
|
|||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# Build the whole package (server + client bins): the conformance crate is
|
||||
# excluded from the workspace default-members, so this is the only CI job
|
||||
# that catches compile breakage in it.
|
||||
# excluded from the workspace default-members.
|
||||
- name: Build conformance binaries
|
||||
run: cargo build -p mcp-conformance
|
||||
|
||||
|
|
@ -75,3 +74,33 @@ jobs:
|
|||
with:
|
||||
name: conformance-server-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
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ impl ClientHandler for FullClientHandler {
|
|||
|
||||
const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json";
|
||||
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.
|
||||
///
|
||||
|
|
@ -365,13 +366,10 @@ async fn run_auth_scope_step_up_client(
|
|||
// Drop old client, re-auth with upgraded scopes
|
||||
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?;
|
||||
// Pass the escalated scope hint
|
||||
oauth2
|
||||
.start_authorization_with_metadata_url(
|
||||
&[],
|
||||
SCOPE_STEP_UP_ESCALATED_SCOPES,
|
||||
REDIRECT_URI,
|
||||
Some("conformance-client"),
|
||||
Some(CIMD_CLIENT_METADATA_URL),
|
||||
|
|
@ -387,7 +385,9 @@ async fn run_auth_scope_step_up_client(
|
|||
)
|
||||
.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 transport2 = StreamableHttpClientTransport::with_client(
|
||||
auth_client2,
|
||||
|
|
@ -435,7 +435,9 @@ async fn run_auth_scope_retry_limit_client(
|
|||
)
|
||||
.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 transport = StreamableHttpClientTransport::with_client(
|
||||
auth_client,
|
||||
|
|
@ -443,7 +445,18 @@ async fn run_auth_scope_retry_limit_client(
|
|||
);
|
||||
|
||||
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;
|
||||
for tool in &tools.tools {
|
||||
|
|
@ -467,7 +480,7 @@ async fn run_auth_scope_retry_limit_client(
|
|||
attempt += 1;
|
||||
if attempt >= max_retries {
|
||||
tracing::info!("Reached retry limit ({max_retries}), giving up");
|
||||
return Err(anyhow::anyhow!("Scope retry limit reached"));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION;
|
|||
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
|
||||
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] = &[
|
||||
"metadata",
|
||||
"metadata.google.internal",
|
||||
|
|
@ -894,11 +900,31 @@ impl AuthorizationManager {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_allowed_authorization_server_metadata_url(url: &Url) -> bool {
|
||||
Self::is_http_url(url)
|
||||
&& url
|
||||
fn is_loopback_metadata_host(host: &str) -> bool {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| 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(|host| !Self::is_disallowed_metadata_host(host))
|
||||
.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> {
|
||||
|
|
@ -1077,9 +1103,25 @@ impl AuthorizationManager {
|
|||
return Ok(metadata);
|
||||
}
|
||||
|
||||
// No valid authorization metadata found - return error instead of guessing
|
||||
// OAuth endpoints must be discovered from the server, not constructed by the client
|
||||
Err(AuthError::NoAuthorizationSupport)
|
||||
debug!("falling back to legacy OAuth endpoints derived from the base URL");
|
||||
Ok(Self::legacy_authorization_metadata(&self.base_url))
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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}`");
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1937,6 +1979,7 @@ impl AuthorizationManager {
|
|||
&& actual == expected.trim_end_matches('/'))
|
||||
|| (Self::is_root_resource_identifier(actual)
|
||||
&& expected == actual.trim_end_matches('/'))
|
||||
|| Self::root_resource_identifier_covers_path(actual, expected)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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> {
|
||||
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));
|
||||
}
|
||||
|
|
@ -1960,8 +2018,9 @@ impl AuthorizationManager {
|
|||
discovery_url.set_query(None);
|
||||
discovery_url.set_fragment(None);
|
||||
discovery_url.set_path(&candidate_path);
|
||||
if let Ok(Some(resource_metadata_url)) =
|
||||
self.fetch_resource_metadata_url(&discovery_url).await
|
||||
if let Ok(Some(resource_metadata_url)) = self
|
||||
.fetch_resource_metadata_url(&discovery_url, false)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(resource_metadata_url));
|
||||
}
|
||||
|
|
@ -1972,7 +2031,11 @@ impl AuthorizationManager {
|
|||
|
||||
/// 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
|
||||
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 {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
|
|
@ -1981,16 +2044,64 @@ impl AuthorizationManager {
|
|||
}
|
||||
};
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
return Ok(Some(url.clone()));
|
||||
} else if response.status() != StatusCode::UNAUTHORIZED {
|
||||
match response.status() {
|
||||
StatusCode::OK => Ok(Some(url.clone())),
|
||||
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!(
|
||||
"resource metadata probe returned unexpected status: {}",
|
||||
"resource metadata POST probe returned unexpected status: {}",
|
||||
response.status()
|
||||
);
|
||||
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;
|
||||
for value in response.headers().get_all(WWW_AUTHENTICATE).iter() {
|
||||
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(
|
||||
|
|
@ -3227,6 +3338,13 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
fn empty_response(status: u16) -> HttpResponse {
|
||||
oauth2::http::Response::builder()
|
||||
.status(status)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_http_client_handles_protected_resource_discovery() {
|
||||
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]
|
||||
async fn discovery_get_follows_same_origin_redirects() {
|
||||
let client = RecordingOAuthHttpClient::with_responses(vec![
|
||||
|
|
@ -3371,6 +3664,7 @@ mod tests {
|
|||
"resource": "https://mcp.example.com/mcp",
|
||||
"authorization_servers": [
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://127.0.0.1:8080/tenant1",
|
||||
"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]
|
||||
async fn protected_resource_discovery_rejects_mismatched_resource() {
|
||||
let challenge = oauth2::http::Response::builder()
|
||||
|
|
@ -3493,6 +3844,10 @@ mod tests {
|
|||
"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(
|
||||
"https://mcp.example.com/mcp",
|
||||
|
|
@ -3502,6 +3857,14 @@ mod tests {
|
|||
"https://mcp.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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
sse_stream: impl Stream<Item = Result<ServerJsonRpcMessage, StreamableHttpError<C::Error>>>
|
||||
+ Send
|
||||
|
|
@ -880,8 +939,17 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
|
|||
&mut pending_stream_response_ids,
|
||||
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(
|
||||
Self::raw_sse_to_jsonrpc(stream),
|
||||
sse_stream,
|
||||
sse_worker_tx.clone(),
|
||||
true,
|
||||
transport_task_ct.child_token(),
|
||||
|
|
@ -913,8 +981,17 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
|
|||
&mut pending_stream_response_ids,
|
||||
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(
|
||||
Self::raw_sse_to_jsonrpc(stream),
|
||||
sse_stream,
|
||||
sse_worker_tx.clone(),
|
||||
true,
|
||||
transport_task_ct.child_token(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue