use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ProtectedResourceMetadataError {
#[error("invalid protected resource URL: {0}")]
InvalidResourceUrl(#[source] url::ParseError),
#[error("protected resource URL must use http or https, got {0}")]
UnsupportedResourceScheme(String),
#[error("protected resource URL must not contain a fragment")]
ResourceHasFragment,
#[error("protected resource metadata must advertise at least one authorization server")]
MissingAuthorizationServer,
#[error("invalid authorization server URL {url}: {source}")]
InvalidAuthorizationServerUrl {
url: String,
#[source]
source: url::ParseError,
},
#[error("authorization server URL must use http or https, got {0}")]
UnsupportedAuthorizationServerScheme(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authorization_servers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes_supported: Vec<String>,
#[serde(default = "default_bearer_methods")]
pub bearer_methods_supported: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_documentation: Option<String>,
}
fn default_bearer_methods() -> Vec<String> {
vec!["header".to_string()]
}
impl ProtectedResourceMetadata {
pub fn new(resource: impl Into<String>) -> Self {
Self {
resource: resource.into(),
authorization_servers: Vec::new(),
scopes_supported: Vec::new(),
bearer_methods_supported: default_bearer_methods(),
resource_documentation: None,
}
}
pub fn authorization_server(mut self, issuer_url: impl Into<String>) -> Self {
self.authorization_servers.push(issuer_url.into());
self
}
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scopes_supported.push(scope.into());
self
}
pub fn resource_documentation(mut self, url: impl Into<String>) -> Self {
self.resource_documentation = Some(url.into());
self
}
pub fn bearer_methods(mut self, methods: Vec<String>) -> Self {
self.bearer_methods_supported = methods;
self
}
pub fn well_known_path() -> &'static str {
"/.well-known/oauth-protected-resource"
}
pub fn well_known_path_for_resource(
resource: &str,
) -> Result<String, ProtectedResourceMetadataError> {
let url = Self::parse_resource_url(resource)?;
let resource_path = url.path();
if resource_path.is_empty() || resource_path == "/" {
Ok(Self::well_known_path().to_string())
} else {
Ok(format!("{}{}", Self::well_known_path(), resource_path))
}
}
pub fn well_known_url(&self) -> Result<String, ProtectedResourceMetadataError> {
let url = Self::parse_resource_url(&self.resource)?;
let path = Self::well_known_path_for_resource(&self.resource)?;
Ok(format!("{}{}", url.origin().ascii_serialization(), path))
}
pub fn validate(&self) -> Result<(), ProtectedResourceMetadataError> {
Self::parse_resource_url(&self.resource)?;
if self.authorization_servers.is_empty() {
return Err(ProtectedResourceMetadataError::MissingAuthorizationServer);
}
for issuer in &self.authorization_servers {
let url = Url::parse(issuer).map_err(|source| {
ProtectedResourceMetadataError::InvalidAuthorizationServerUrl {
url: issuer.clone(),
source,
}
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(
ProtectedResourceMetadataError::UnsupportedAuthorizationServerScheme(
url.scheme().to_string(),
),
);
}
}
Ok(())
}
fn parse_resource_url(resource: &str) -> Result<Url, ProtectedResourceMetadataError> {
let url =
Url::parse(resource).map_err(ProtectedResourceMetadataError::InvalidResourceUrl)?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ProtectedResourceMetadataError::UnsupportedResourceScheme(
url.scheme().to_string(),
));
}
if url.fragment().is_some() {
return Err(ProtectedResourceMetadataError::ResourceHasFragment);
}
Ok(url)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder() {
let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
.authorization_server("https://auth.example.com")
.scope("mcp:read")
.scope("mcp:write")
.resource_documentation("https://docs.example.com");
assert_eq!(metadata.resource, "https://mcp.example.com");
assert_eq!(
metadata.authorization_servers,
vec!["https://auth.example.com"]
);
assert_eq!(metadata.scopes_supported, vec!["mcp:read", "mcp:write"]);
assert_eq!(metadata.bearer_methods_supported, vec!["header"]);
assert_eq!(
metadata.resource_documentation.as_deref(),
Some("https://docs.example.com")
);
}
#[test]
fn test_serialization() {
let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
.authorization_server("https://auth.example.com")
.scope("mcp:read");
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(json["resource"], "https://mcp.example.com");
assert_eq!(json["authorization_servers"][0], "https://auth.example.com");
assert_eq!(json["scopes_supported"][0], "mcp:read");
assert_eq!(json["bearer_methods_supported"][0], "header");
assert!(json.get("resource_documentation").is_none());
}
#[test]
fn test_deserialization() {
let json = serde_json::json!({
"resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["mcp:read"],
"bearer_methods_supported": ["header"]
});
let metadata: ProtectedResourceMetadata = serde_json::from_value(json).unwrap();
assert_eq!(metadata.resource, "https://mcp.example.com");
assert_eq!(metadata.authorization_servers.len(), 1);
assert_eq!(metadata.scopes_supported.len(), 1);
}
#[test]
fn test_well_known_path() {
assert_eq!(
ProtectedResourceMetadata::well_known_path(),
"/.well-known/oauth-protected-resource"
);
}
#[test]
fn test_multiple_auth_servers() {
let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
.authorization_server("https://auth1.example.com")
.authorization_server("https://auth2.example.com");
assert_eq!(metadata.authorization_servers.len(), 2);
}
#[test]
fn test_path_aware_well_known_location() {
let metadata = ProtectedResourceMetadata::new("https://mcp.example.com/tenant/mcp?x=1")
.authorization_server("https://auth.example.com");
assert_eq!(
metadata.well_known_url().unwrap(),
"https://mcp.example.com/.well-known/oauth-protected-resource/tenant/mcp"
);
assert_eq!(
ProtectedResourceMetadata::well_known_path_for_resource(&metadata.resource).unwrap(),
"/.well-known/oauth-protected-resource/tenant/mcp"
);
}
#[test]
fn test_path_aware_location_preserves_encoded_path_segments() {
let metadata = ProtectedResourceMetadata::new("https://mcp.example.com/a%2Fb")
.authorization_server("https://auth.example.com");
assert_eq!(
metadata.well_known_url().unwrap(),
"https://mcp.example.com/.well-known/oauth-protected-resource/a%2Fb"
);
}
#[test]
fn test_validate_requires_authorization_server() {
let error = ProtectedResourceMetadata::new("https://mcp.example.com")
.validate()
.unwrap_err();
assert!(matches!(
error,
ProtectedResourceMetadataError::MissingAuthorizationServer
));
}
#[test]
fn test_validate_rejects_resource_fragment() {
let error = ProtectedResourceMetadata::new("https://mcp.example.com#fragment")
.authorization_server("https://auth.example.com")
.validate()
.unwrap_err();
assert!(matches!(
error,
ProtectedResourceMetadataError::ResourceHasFragment
));
}
}