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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue