Skip to main content

tower_mcp/oauth/
metadata.rs

1//! Protected Resource Metadata (RFC 9728 Section 3).
2//!
3//! Defines the metadata document served at `/.well-known/oauth-protected-resource`
4//! to enable OAuth 2.1 client discovery of authorization servers.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use url::Url;
9
10/// Errors returned when validating MCP Protected Resource Metadata.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum ProtectedResourceMetadataError {
14    /// The resource identifier is not a valid absolute URL.
15    #[error("invalid protected resource URL: {0}")]
16    InvalidResourceUrl(#[source] url::ParseError),
17
18    /// MCP HTTP resource identifiers must use HTTP or HTTPS.
19    #[error("protected resource URL must use http or https, got {0}")]
20    UnsupportedResourceScheme(String),
21
22    /// Resource identifiers cannot contain URL fragments.
23    #[error("protected resource URL must not contain a fragment")]
24    ResourceHasFragment,
25
26    /// MCP Protected Resource Metadata must advertise an authorization server.
27    #[error("protected resource metadata must advertise at least one authorization server")]
28    MissingAuthorizationServer,
29
30    /// An advertised authorization-server issuer is not a valid absolute URL.
31    #[error("invalid authorization server URL {url}: {source}")]
32    InvalidAuthorizationServerUrl {
33        /// The invalid issuer URL.
34        url: String,
35        /// The URL parsing failure.
36        #[source]
37        source: url::ParseError,
38    },
39
40    /// An advertised authorization-server issuer does not use HTTP(S).
41    #[error("authorization server URL must use http or https, got {0}")]
42    UnsupportedAuthorizationServerScheme(String),
43}
44
45/// Protected Resource Metadata per RFC 9728 Section 3.
46///
47/// This metadata document tells OAuth clients which authorization server(s)
48/// to use and what scopes are available. It is served at
49/// the RFC 9728 well-known location for the resource URL. For a resource with
50/// a path, such as `https://example.com/mcp`, that location is
51/// `https://example.com/.well-known/oauth-protected-resource/mcp`.
52///
53/// # Example
54///
55/// ```rust
56/// use tower_mcp::oauth::ProtectedResourceMetadata;
57///
58/// let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
59///     .authorization_server("https://auth.example.com")
60///     .scope("mcp:read")
61///     .scope("mcp:write")
62///     .resource_documentation("https://docs.example.com/mcp");
63/// ```
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ProtectedResourceMetadata {
66    /// The resource server's identifier URL.
67    ///
68    /// This MUST be the URL the client uses to access the resource.
69    pub resource: String,
70
71    /// Authorization server issuer URLs that can issue tokens for this resource.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub authorization_servers: Vec<String>,
74
75    /// OAuth scopes supported by this resource server.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub scopes_supported: Vec<String>,
78
79    /// Methods supported for sending bearer tokens.
80    ///
81    /// Defaults to `["header"]` per RFC 6750.
82    #[serde(default = "default_bearer_methods")]
83    pub bearer_methods_supported: Vec<String>,
84
85    /// URL of documentation for this resource.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub resource_documentation: Option<String>,
88}
89
90fn default_bearer_methods() -> Vec<String> {
91    vec!["header".to_string()]
92}
93
94impl ProtectedResourceMetadata {
95    /// Create new metadata with the resource server's identifier URL.
96    pub fn new(resource: impl Into<String>) -> Self {
97        Self {
98            resource: resource.into(),
99            authorization_servers: Vec::new(),
100            scopes_supported: Vec::new(),
101            bearer_methods_supported: default_bearer_methods(),
102            resource_documentation: None,
103        }
104    }
105
106    /// Add an authorization server issuer URL.
107    pub fn authorization_server(mut self, issuer_url: impl Into<String>) -> Self {
108        self.authorization_servers.push(issuer_url.into());
109        self
110    }
111
112    /// Add a supported OAuth scope.
113    pub fn scope(mut self, scope: impl Into<String>) -> Self {
114        self.scopes_supported.push(scope.into());
115        self
116    }
117
118    /// Set the resource documentation URL.
119    pub fn resource_documentation(mut self, url: impl Into<String>) -> Self {
120        self.resource_documentation = Some(url.into());
121        self
122    }
123
124    /// Set the bearer methods supported.
125    pub fn bearer_methods(mut self, methods: Vec<String>) -> Self {
126        self.bearer_methods_supported = methods;
127        self
128    }
129
130    /// Returns the well-known path for this metadata endpoint.
131    ///
132    /// This is the prefix used for root resources. For resources with a path,
133    /// use [`Self::well_known_path_for_resource`].
134    pub fn well_known_path() -> &'static str {
135        "/.well-known/oauth-protected-resource"
136    }
137
138    /// Return the path-aware RFC 9728 well-known path for a resource URL.
139    ///
140    /// A resource at `https://example.com/mcp` maps to
141    /// `/.well-known/oauth-protected-resource/mcp`. Query and fragment
142    /// components are not copied to the metadata endpoint.
143    pub fn well_known_path_for_resource(
144        resource: &str,
145    ) -> Result<String, ProtectedResourceMetadataError> {
146        let url = Self::parse_resource_url(resource)?;
147        let resource_path = url.path();
148        if resource_path.is_empty() || resource_path == "/" {
149            Ok(Self::well_known_path().to_string())
150        } else {
151            Ok(format!("{}{}", Self::well_known_path(), resource_path))
152        }
153    }
154
155    /// Return the absolute RFC 9728 metadata URL for this resource.
156    pub fn well_known_url(&self) -> Result<String, ProtectedResourceMetadataError> {
157        let url = Self::parse_resource_url(&self.resource)?;
158        let path = Self::well_known_path_for_resource(&self.resource)?;
159        Ok(format!("{}{}", url.origin().ascii_serialization(), path))
160    }
161
162    /// Validate metadata required by an MCP OAuth resource server.
163    ///
164    /// This checks that the resource is an absolute HTTP(S) URL without a
165    /// fragment and that at least one valid authorization-server URL is
166    /// advertised.
167    pub fn validate(&self) -> Result<(), ProtectedResourceMetadataError> {
168        Self::parse_resource_url(&self.resource)?;
169        if self.authorization_servers.is_empty() {
170            return Err(ProtectedResourceMetadataError::MissingAuthorizationServer);
171        }
172        for issuer in &self.authorization_servers {
173            let url = Url::parse(issuer).map_err(|source| {
174                ProtectedResourceMetadataError::InvalidAuthorizationServerUrl {
175                    url: issuer.clone(),
176                    source,
177                }
178            })?;
179            if !matches!(url.scheme(), "http" | "https") {
180                return Err(
181                    ProtectedResourceMetadataError::UnsupportedAuthorizationServerScheme(
182                        url.scheme().to_string(),
183                    ),
184                );
185            }
186        }
187        Ok(())
188    }
189
190    fn parse_resource_url(resource: &str) -> Result<Url, ProtectedResourceMetadataError> {
191        let url =
192            Url::parse(resource).map_err(ProtectedResourceMetadataError::InvalidResourceUrl)?;
193        if !matches!(url.scheme(), "http" | "https") {
194            return Err(ProtectedResourceMetadataError::UnsupportedResourceScheme(
195                url.scheme().to_string(),
196            ));
197        }
198        if url.fragment().is_some() {
199            return Err(ProtectedResourceMetadataError::ResourceHasFragment);
200        }
201        Ok(url)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_builder() {
211        let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
212            .authorization_server("https://auth.example.com")
213            .scope("mcp:read")
214            .scope("mcp:write")
215            .resource_documentation("https://docs.example.com");
216
217        assert_eq!(metadata.resource, "https://mcp.example.com");
218        assert_eq!(
219            metadata.authorization_servers,
220            vec!["https://auth.example.com"]
221        );
222        assert_eq!(metadata.scopes_supported, vec!["mcp:read", "mcp:write"]);
223        assert_eq!(metadata.bearer_methods_supported, vec!["header"]);
224        assert_eq!(
225            metadata.resource_documentation.as_deref(),
226            Some("https://docs.example.com")
227        );
228    }
229
230    #[test]
231    fn test_serialization() {
232        let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
233            .authorization_server("https://auth.example.com")
234            .scope("mcp:read");
235
236        let json = serde_json::to_value(&metadata).unwrap();
237        assert_eq!(json["resource"], "https://mcp.example.com");
238        assert_eq!(json["authorization_servers"][0], "https://auth.example.com");
239        assert_eq!(json["scopes_supported"][0], "mcp:read");
240        assert_eq!(json["bearer_methods_supported"][0], "header");
241        // resource_documentation should be absent (None)
242        assert!(json.get("resource_documentation").is_none());
243    }
244
245    #[test]
246    fn test_deserialization() {
247        let json = serde_json::json!({
248            "resource": "https://mcp.example.com",
249            "authorization_servers": ["https://auth.example.com"],
250            "scopes_supported": ["mcp:read"],
251            "bearer_methods_supported": ["header"]
252        });
253
254        let metadata: ProtectedResourceMetadata = serde_json::from_value(json).unwrap();
255        assert_eq!(metadata.resource, "https://mcp.example.com");
256        assert_eq!(metadata.authorization_servers.len(), 1);
257        assert_eq!(metadata.scopes_supported.len(), 1);
258    }
259
260    #[test]
261    fn test_well_known_path() {
262        assert_eq!(
263            ProtectedResourceMetadata::well_known_path(),
264            "/.well-known/oauth-protected-resource"
265        );
266    }
267
268    #[test]
269    fn test_multiple_auth_servers() {
270        let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
271            .authorization_server("https://auth1.example.com")
272            .authorization_server("https://auth2.example.com");
273
274        assert_eq!(metadata.authorization_servers.len(), 2);
275    }
276
277    #[test]
278    fn test_path_aware_well_known_location() {
279        let metadata = ProtectedResourceMetadata::new("https://mcp.example.com/tenant/mcp?x=1")
280            .authorization_server("https://auth.example.com");
281
282        assert_eq!(
283            metadata.well_known_url().unwrap(),
284            "https://mcp.example.com/.well-known/oauth-protected-resource/tenant/mcp"
285        );
286        assert_eq!(
287            ProtectedResourceMetadata::well_known_path_for_resource(&metadata.resource).unwrap(),
288            "/.well-known/oauth-protected-resource/tenant/mcp"
289        );
290    }
291
292    #[test]
293    fn test_path_aware_location_preserves_encoded_path_segments() {
294        let metadata = ProtectedResourceMetadata::new("https://mcp.example.com/a%2Fb")
295            .authorization_server("https://auth.example.com");
296        assert_eq!(
297            metadata.well_known_url().unwrap(),
298            "https://mcp.example.com/.well-known/oauth-protected-resource/a%2Fb"
299        );
300    }
301
302    #[test]
303    fn test_validate_requires_authorization_server() {
304        let error = ProtectedResourceMetadata::new("https://mcp.example.com")
305            .validate()
306            .unwrap_err();
307        assert!(matches!(
308            error,
309            ProtectedResourceMetadataError::MissingAuthorizationServer
310        ));
311    }
312
313    #[test]
314    fn test_validate_rejects_resource_fragment() {
315        let error = ProtectedResourceMetadata::new("https://mcp.example.com#fragment")
316            .authorization_server("https://auth.example.com")
317            .validate()
318            .unwrap_err();
319        assert!(matches!(
320            error,
321            ProtectedResourceMetadataError::ResourceHasFragment
322        ));
323    }
324}