Skip to main content

protocol_droid/domain/mcp/
resource_metadata.rs

1//! The canonical resource identifier and the document that publishes it.
2//!
3//! RFC 9728 ("OAuth 2.0 Protected Resource Metadata") is what makes an MCP
4//! server discoverable as an OAuth resource server: a server publishes a
5//! metadata document naming its authorization servers, and a client that gets
6//! a `401` reads it to find out where to get a token.
7//!
8//! That document's `resource` field *is* the audience. RFC 8707 resource
9//! indicators bind a token to it, and this crate refuses a token whose audience
10//! is not exactly this string. So [`ResourceUri`] is a value object rather than
11//! a convenience: two spellings of the same server that compare unequal is a
12//! token rejected for a reason nobody can see.
13//!
14//! # Where this lives
15//!
16//! `domain/`, because the canonicalisation rules are the security-relevant
17//! part and an adapter should not get to choose them. Which *revisions* require
18//! any of this is `authorization_model`.
19
20use std::sync::Arc;
21
22use core::fmt;
23use core::str::FromStr;
24
25use serde::{Serialize, Serializer};
26
27/// The well-known path an MCP server publishes its metadata at.
28///
29/// RFC 9728 §3.1: the metadata for a resource at the origin root is
30/// `/.well-known/oauth-protected-resource`, and the metadata for a resource at
31/// some other path is `/.well-known/oauth-protected-resource` with that path
32/// *appended*. So a server mounted at `/mcp` publishes at
33/// `/.well-known/oauth-protected-resource/mcp` — see
34/// [`ResourceUri::metadata_path`].
35pub const WELL_KNOWN: &str = "/.well-known/oauth-protected-resource";
36
37/// How a bearer token reaches the server.
38///
39/// RFC 9728 §2: `bearer_methods_supported` names the mechanisms this resource
40/// accepts. Only the `header` mechanism is defined by the RFC, and it is the
41/// only one this crate reads a token from — the alternative in the spec's
42/// discussion is the `Authorization` request body parameter, which MCP's
43/// transports have no place for.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum BearerMethod {
47    /// RFC 6750 §2.1, the `Authorization: Bearer` header.
48    ///
49    /// The wire spelling is lower case because that is what the RFC's
50    /// `bearer_methods_supported` array contains and what a client's parser
51    /// compares against.
52    #[default]
53    Header,
54}
55
56impl BearerMethod {
57    /// The value as it appears in a `bearer_methods_supported` array.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Header => "header",
62        }
63    }
64}
65
66/// The canonical resource identifier for an MCP server: the `resource` field,
67/// and the audience a token must carry.
68///
69/// # Canonicalisation
70///
71/// Two rules, both from RFC 9728 §2 and RFC 8707 §2, and both of which a
72/// hand-rolled string comparison would get wrong:
73///
74/// - **No trailing slash.** `https://example.com/mcp` and
75///   `https://example.com/mcp/` are the same resource to a human and two
76///   different strings to an `aud` comparison, so the slash is dropped once.
77///   A bare origin (`https://example.com`) keeps none.
78/// - **No default port.** `https://example.com:443/mcp` and
79///   `https://example.com/mcp` are the same resource, so `443` and `80` are
80///   dropped for their schemes.
81///
82/// Scheme and host are **not** case-folded beyond parsing the scheme, because
83/// the host part is not something this crate can safely normalise: IDNA
84/// punycode and case-sensitive comparison are a much larger problem than
85/// authorization needs, and a wrong fold is a wrong audience. What is folded is
86/// the scheme, which RFC 3986 §3.1 defines as case-insensitive.
87///
88/// # Examples
89///
90/// ```
91/// use protocol_droid::domain::mcp::ResourceUri;
92///
93/// // The trailing slash is dropped, so both spellings name one resource.
94/// assert_eq!(
95///     ResourceUri::new("https://example.com/mcp/").unwrap().as_str(),
96///     "https://example.com/mcp",
97/// );
98/// assert_eq!(
99///     ResourceUri::new("https://example.com/mcp").unwrap(),
100///     ResourceUri::new("https://example.com/mcp/").unwrap(),
101/// );
102///
103/// // A default port is dropped too.
104/// assert_eq!(
105///     ResourceUri::new("HTTPS://example.com:443/mcp").unwrap().as_str(),
106///     "https://example.com/mcp",
107/// );
108///
109/// // A non-default port is part of the resource and is kept.
110/// assert_eq!(
111///     ResourceUri::new("https://example.com:8443/mcp").unwrap().as_str(),
112///     "https://example.com:8443/mcp",
113/// );
114/// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
115/// ```
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
117pub struct ResourceUri(Arc<str>);
118
119impl Serialize for ResourceUri {
120    /// Serialises as the canonical string, which is the RFC 9728 `resource`
121    /// value verbatim.
122    ///
123    /// Hand-written rather than derived because the inner `Arc<str>` is a
124    /// newtype's implementation detail — a derived impl would make the
125    /// canonical form load-bearing in a second place, and the whole point of
126    /// the type is that there is exactly one spelling of a resource.
127    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
128        serializer.serialize_str(self.as_str())
129    }
130}
131
132impl ResourceUri {
133    /// Canonicalises a resource identifier.
134    ///
135    /// The inherent twin of [`FromStr`], so a caller reading this module does
136    /// not have to know that the parsing rule lives in a trait impl. Both do
137    /// the same thing; `new` exists because the `parse` in a doctest is noise
138    /// when the value came from a config file rather than from user input.
139    ///
140    /// # Errors
141    ///
142    /// The same as [`ResourceUri::from_str`].
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use protocol_droid::domain::mcp::ResourceUri;
148    ///
149    /// assert_eq!(
150    ///     ResourceUri::new("https://example.com/mcp/")?.as_str(),
151    ///     "https://example.com/mcp",
152    /// );
153    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
154    /// ```
155    pub fn new(value: &str) -> Result<Self, ResourceUriError> {
156        value.parse()
157    }
158
159    /// The canonical string, which is also the `resource` field and the
160    /// expected `aud`.
161    #[must_use]
162    pub fn as_str(&self) -> &str {
163        &self.0
164    }
165
166    /// Builds a canonical identifier from a base origin and a mounted path.
167    ///
168    /// The join is done here rather than by the caller because the two halves
169    /// each arrive from somewhere that gets slash handling wrong: a config file
170    /// writes a trailing slash on the origin, and the mount point is an
171    /// [`Endpoint`](crate::adapters::primary::transport::http::Endpoint) that already requires a leading one.
172    ///
173    /// # Errors
174    ///
175    /// Whatever [`Self::new`] returns for the joined string.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use protocol_droid::domain::mcp::ResourceUri;
181    ///
182    /// let uri = ResourceUri::from_parts("https://example.com/", "/mcp")?;
183    /// assert_eq!(uri.as_str(), "https://example.com/mcp");
184    ///
185    /// // An origin with no path still produces a usable identifier.
186    /// let root = ResourceUri::from_parts("https://example.com", "")?;
187    /// assert_eq!(root.as_str(), "https://example.com");
188    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
189    /// ```
190    pub fn from_parts(origin: &str, path: &str) -> Result<Self, ResourceUriError> {
191        let base = origin.trim_end_matches('/');
192        // A path of `/` means the resource *is* the origin; anything else is
193        // appended to the origin with exactly one slash between them.
194        let trimmed = path.trim_matches('/');
195        let joined = if trimmed.is_empty() {
196            base.to_owned()
197        } else {
198            format!("{base}/{trimmed}")
199        };
200        Self::new(&joined)
201    }
202
203    /// The RFC 9728 path this resource's metadata is published at.
204    ///
205    /// The resource's own path appended to [`WELL_KNOWN`], which is the
206    /// path-inserted form from RFC 9728 §3.1. A resource at the origin root
207    /// publishes at exactly [`WELL_KNOWN`].
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use protocol_droid::domain::mcp::ResourceUri;
213    ///
214    /// assert_eq!(
215    ///     ResourceUri::new("https://example.com/mcp").unwrap().metadata_path(),
216    ///     "/.well-known/oauth-protected-resource/mcp",
217    /// );
218    /// assert_eq!(
219    ///     ResourceUri::new("https://example.com").unwrap().metadata_path(),
220    ///     "/.well-known/oauth-protected-resource",
221    /// );
222    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
223    /// ```
224    #[must_use]
225    pub fn metadata_path(&self) -> String {
226        let Some(path) = self.path() else {
227            return WELL_KNOWN.to_owned();
228        };
229        format!("{WELL_KNOWN}{path}")
230    }
231
232    /// The absolute URL a `WWW-Authenticate` challenge points at.
233    ///
234    /// # Errors
235    ///
236    /// [`ResourceUriError::NoOrigin`] when this identifier has no origin, which
237    /// [`Self::new`] rules out — so this cannot fire from a `ResourceUri` that
238    /// was built through the constructor, and exists only so the return type is
239    /// honest about the fallback rather than panicking.
240    ///
241    /// # Examples
242    ///
243    /// ```
244    /// use protocol_droid::domain::mcp::ResourceUri;
245    ///
246    /// let uri = ResourceUri::new("https://example.com/mcp")?;
247    /// assert_eq!(
248    ///     uri.metadata_url()?,
249    ///     "https://example.com/.well-known/oauth-protected-resource/mcp",
250    /// );
251    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
252    /// ```
253    pub fn metadata_url(&self) -> Result<String, ResourceUriError> {
254        let origin = self.origin().ok_or(ResourceUriError::NoOrigin)?;
255        Ok(format!("{origin}{}", self.metadata_path()))
256    }
257
258    /// The origin — scheme, host and any non-default port — with no path.
259    fn origin(&self) -> Option<&str> {
260        let after_scheme = self.0.find("://")? + 3;
261        let end = self.0[after_scheme..]
262            .find('/')
263            .map_or(self.0.len(), |offset| after_scheme + offset);
264        Some(&self.0[..end])
265    }
266
267    /// The path component, without the origin and without a trailing slash.
268    fn path(&self) -> Option<&str> {
269        let after_scheme = self.0.find("://")? + 3;
270        let slash = self.0[after_scheme..].find('/')? + after_scheme;
271        Some(&self.0[slash..])
272    }
273}
274
275impl FromStr for ResourceUri {
276    type Err = ResourceUriError;
277
278    /// # Errors
279    ///
280    /// [`ResourceUriError::Empty`] for a blank value, and
281    /// [`ResourceUriError::NotAbsolute`] when there is no `scheme://`. RFC 9728
282    /// §2 requires `resource` to be an absolute URI, so a relative one is not
283    /// a resource identifier and cannot be compared against an `aud`.
284    ///
285    /// A trailing slash and a default port are canonicalised rather than
286    /// refused — see the type docs for why that is the right call.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use protocol_droid::domain::mcp::ResourceUri;
292    ///
293    /// assert!(ResourceUri::new("example.com/mcp").is_err());
294    /// assert!(ResourceUri::new("  ").is_err());
295    /// assert!(ResourceUri::new("https://example.com/mcp").is_ok());
296    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
297    /// ```
298    fn from_str(value: &str) -> Result<Self, Self::Err> {
299        let trimmed = value.trim();
300        if trimmed.is_empty() {
301            return Err(ResourceUriError::Empty);
302        }
303
304        let Some(scheme_end) = trimmed.find("://") else {
305            return Err(ResourceUriError::NotAbsolute);
306        };
307        let scheme = &trimmed[..scheme_end];
308        if scheme.is_empty()
309            || !scheme
310                .bytes()
311                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
312        {
313            return Err(ResourceUriError::NotAbsolute);
314        }
315
316        // RFC 3986 §3.1: the scheme is case-insensitive. Only the scheme —
317        // see the type docs on why the host is not folded.
318        let mut canonical = String::with_capacity(trimmed.len());
319        canonical.push_str(&scheme.to_ascii_lowercase());
320        canonical.push_str("://");
321
322        let rest = &trimmed[scheme_end + 3..];
323        let (authority, path) = rest
324            .find('/')
325            .map_or((rest, ""), |slash| (&rest[..slash], &rest[slash..]));
326        if authority.is_empty() {
327            return Err(ResourceUriError::NoAuthority);
328        }
329        canonical.push_str(&drop_default_port(authority, scheme));
330        // Exactly one trailing slash is dropped, and only from the end of a
331        // non-empty path, so `/mcp//` keeps the second slash rather than
332        // quietly collapsing a path that is genuinely different.
333        let path = path.strip_suffix('/').unwrap_or(path);
334        canonical.push_str(path);
335
336        Ok(Self(Arc::from(canonical.as_str())))
337    }
338}
339
340/// `authority` without its port when the port is the scheme's default.
341///
342/// Compares the scheme case-insensitively because the caller passes the raw
343/// scheme, not the canonicalised one, and `HTTPS://example.com:443` has to
344/// fold the same way `https://example.com:443` does.
345///
346/// The port separator is *not* always the last colon. In an IPv6 literal every
347/// group is separated by one — `[::1]:8443` has three — so a `rsplit(':')` cuts
348/// the host in half and then compares whatever followed to the default port.
349/// The closing bracket is what marks the end of the host in that case, and
350/// getting this wrong silently drops or keeps the port on an address nobody
351/// writes as a hostname.
352fn drop_default_port(authority: &str, scheme: &str) -> String {
353    let default = match scheme.to_ascii_lowercase().as_str() {
354        "https" => Some("443"),
355        "http" => Some("80"),
356        _ => None,
357    };
358
359    // For a bracketed literal the host is everything through the `]`, and the
360    // separator is the first `:` after it. `find` on the tail returns an offset
361    // *into the tail*, so the addition that steps past the bracket has to happen
362    // inside the closure — chained after it, `close` is gone and only the
363    // wrong-index number is left. For anything else the last `:` separates.
364    let separator = if authority.starts_with('[') {
365        authority.find(']').and_then(|close| {
366            authority[close + 1..]
367                .find(':')
368                .map(|in_tail| close + 1 + in_tail)
369        })
370    } else {
371        authority.rfind(':')
372    };
373
374    let Some(separator) = separator else {
375        return authority.to_owned();
376    };
377    debug_assert!(authority.is_char_boundary(separator));
378    let (host, port) = authority.split_at(separator);
379    let port = &port[1..];
380
381    if default == Some(port) {
382        return host.to_owned();
383    }
384    authority.to_owned()
385}
386
387impl TryFrom<&str> for ResourceUri {
388    type Error = ResourceUriError;
389
390    /// # Errors
391    ///
392    /// The same as [`ResourceUri::new`].
393    fn try_from(value: &str) -> Result<Self, Self::Error> {
394        value.parse()
395    }
396}
397
398impl fmt::Display for ResourceUri {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        f.write_str(&self.0)
401    }
402}
403
404/// Why a string is not a usable resource identifier.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
406pub enum ResourceUriError {
407    /// The value was blank.
408    #[error("the resource identifier is empty")]
409    Empty,
410
411    /// There was no `scheme://`, or the scheme was not a legal one.
412    ///
413    /// RFC 9728 §2 requires an absolute URI, so this is a refusal rather than
414    /// something to guess at: a relative identifier cannot be compared against
415    /// a token's `aud`, which is the only thing it is for.
416    #[error("the resource identifier is not an absolute URI with an http(s) scheme")]
417    NotAbsolute,
418
419    /// The authority was empty between `://` and the first `/`.
420    #[error("the resource identifier names no host")]
421    NoAuthority,
422
423    /// The derived metadata path was not usable.
424    ///
425    /// Unreachable through [`ResourceUri::new`] — the constructor refuses
426    /// anything without an authority — so it exists to give
427    /// [`ResourceUri::metadata_url`] an error type rather than a panic.
428    #[error("the resource identifier has no origin to publish metadata under")]
429    NoOrigin,
430}
431
432/// An RFC 9728 protected-resource metadata document.
433///
434/// What an MCP server publishes to tell a client which authorization servers can
435/// issue tokens for it, and which scopes those tokens should carry.
436///
437/// # Wire form
438///
439/// Field names are the `snake_case` ones RFC 9728 §2 defines — unlike the rest of
440/// MCP's wire types, so this one derives `Serialize` rather than renaming to
441/// camelCase. `skip_serializing_if` is not an optimisation: §3.2 says a
442/// parameter with zero values MUST be omitted, so an empty
443/// `authorization_servers` is absent rather than `[]`. `resource` is the one
444/// field that is always present.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
446pub struct ResourceMetadata {
447    /// This server's canonical resource identifier. Required by RFC 9728 §2.
448    resource: ResourceUri,
449
450    /// Issuer URLs of the authorization servers that can issue tokens for this
451    /// resource.
452    ///
453    /// A `Vec` rather than a set because RFC 9728 §2 defines an array and the
454    /// order is not significant, but deduplication is still the server's
455    /// decision — publishing the same issuer twice is odd rather than wrong,
456    /// and refusing to publish it would be this type being clever with
457    /// somebody's configuration.
458    #[serde(skip_serializing_if = "Vec::is_empty")]
459    authorization_servers: Vec<Arc<str>>,
460
461    /// Scopes a token for this resource should carry.
462    #[serde(skip_serializing_if = "Vec::is_empty")]
463    scopes_supported: Vec<Arc<str>>,
464
465    /// How a bearer token may be presented. Always [`BearerMethod::Header`].
466    #[serde(skip_serializing_if = "Vec::is_empty")]
467    bearer_methods_supported: Vec<BearerMethod>,
468
469    /// Human-readable name for the resource, from RFC 9728 §2.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    resource_name: Option<Arc<str>>,
472}
473
474impl ResourceMetadata {
475    /// A document for `resource`, with the mandatory `resource` field and
476    /// nothing optional.
477    ///
478    /// # Examples
479    ///
480    /// ```
481    /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
482    ///
483    /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
484    /// assert_eq!(metadata.resource().as_str(), "https://example.com/mcp");
485    /// assert!(metadata.authorization_servers().is_empty());
486    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
487    /// ```
488    #[must_use]
489    pub fn new(resource: ResourceUri) -> Self {
490        Self {
491            resource,
492            authorization_servers: Vec::new(),
493            scopes_supported: Vec::new(),
494            bearer_methods_supported: vec![BearerMethod::Header],
495            resource_name: None,
496        }
497    }
498
499    /// Names an authorization server that can issue tokens for this resource.
500    #[must_use]
501    pub fn with_authorization_server(mut self, issuer: impl Into<Arc<str>>) -> Self {
502        self.authorization_servers.push(issuer.into());
503        self
504    }
505
506    /// Declares a scope a token may carry.
507    #[must_use]
508    pub fn with_scope(mut self, scope: impl Into<Arc<str>>) -> Self {
509        self.scopes_supported.push(scope.into());
510        self
511    }
512
513    /// Sets the human-readable name.
514    #[must_use]
515    pub fn with_name(mut self, name: impl Into<Arc<str>>) -> Self {
516        self.resource_name = Some(name.into());
517        self
518    }
519
520    /// The canonical resource identifier, and the audience a token must carry.
521    #[must_use]
522    pub const fn resource(&self) -> &ResourceUri {
523        &self.resource
524    }
525
526    /// The authorization servers this resource names.
527    #[must_use]
528    pub fn authorization_servers(&self) -> &[Arc<str>] {
529        &self.authorization_servers
530    }
531
532    /// The scopes this resource declares.
533    #[must_use]
534    pub fn scopes_supported(&self) -> &[Arc<str>] {
535        &self.scopes_supported
536    }
537
538    /// How a bearer token may be presented.
539    #[must_use]
540    pub fn bearer_methods_supported(&self) -> &[BearerMethod] {
541        &self.bearer_methods_supported
542    }
543
544    /// The `WWW-Authenticate` challenge for a request that was refused.
545    ///
546    /// RFC 9728 §5.1: the challenge carries `resource_metadata` so the client
547    /// knows where to discover the authorization server, which is the whole
548    /// reason a server publishes the document.
549    ///
550    /// # Errors
551    ///
552    /// [`ResourceUriError::NoOrigin`] when the resource identifier has no
553    /// origin, which the constructor rules out.
554    ///
555    /// # Examples
556    ///
557    /// ```
558    /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
559    ///
560    /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
561    /// assert_eq!(
562    ///     metadata.challenge()?,
563    ///     "Bearer resource_metadata=\"https://example.com/.well-known/oauth-protected-resource/mcp\"",
564    /// );
565    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
566    /// ```
567    pub fn challenge(&self) -> Result<String, ResourceUriError> {
568        Ok(format!(
569            "Bearer resource_metadata=\"{}\"",
570            self.resource.metadata_url()?
571        ))
572    }
573}
574
575#[cfg(test)]
576#[path = "resource_metadata/tests.rs"]
577mod tests;
578
579#[cfg(test)]
580mod wire_tests {
581    use super::{ResourceMetadata, ResourceUri, ResourceUriError};
582
583    /// The fallible calls in this module answer unrelated error types, so the
584    /// test returns a `Result` over this rather than unwrapping. Each variant
585    /// says what went wrong, because a bare "test failed" hides which of the
586    /// two steps it was.
587    /// The first element of a JSON array parameter, or `None` when the value is
588    /// absent or is not an array. Keeps the assertions from indexing.
589    fn first_element(value: &serde_json::Value) -> Option<serde_json::Value> {
590        value.as_array().and_then(|values| values.first().cloned())
591    }
592
593    impl core::fmt::Display for Failure {
594        /// Reads both payloads, so a failure says *which* step broke rather
595        /// than only that something did.
596        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
597            match self {
598                Self::Uri(e) => write!(f, "not a legal resource identifier: {e}"),
599                Self::Json(e) => write!(f, "document did not serialise: {e}"),
600            }
601        }
602    }
603
604    impl std::error::Error for Failure {}
605
606    #[derive(Debug)]
607    enum Failure {
608        /// A literal in the test was not a legal resource identifier.
609        Uri(ResourceUriError),
610        /// The document did not serialise, which would be a bug in the derive.
611        Json(serde_json::Error),
612    }
613
614    #[test]
615    fn emits_rfc_9728_field_names_and_omits_empty_parameters() -> Result<(), Failure> {
616        let full = ResourceMetadata::new(
617            ResourceUri::new("https://example.com/mcp").map_err(Failure::Uri)?,
618        )
619        .with_authorization_server("https://auth.example.com")
620        .with_scope("files:read")
621        .with_name("Example");
622        let full_json: serde_json::Value = serde_json::to_value(&full).map_err(Failure::Json)?;
623        // `get` rather than indexing throughout: a wrong shape then fails an
624        // assertion instead of panicking inside the test.
625        let first = |key: &str| full_json.get(key).and_then(first_element);
626        assert_eq!(
627            full_json.get("resource"),
628            Some(&serde_json::json!("https://example.com/mcp"))
629        );
630        assert_eq!(
631            first("authorization_servers"),
632            Some(serde_json::json!("https://auth.example.com"))
633        );
634        assert_eq!(
635            first("scopes_supported"),
636            Some(serde_json::json!("files:read"))
637        );
638        assert_eq!(
639            first("bearer_methods_supported"),
640            Some(serde_json::json!("header"))
641        );
642        assert_eq!(
643            full_json.get("resource_name"),
644            Some(&serde_json::json!("Example"))
645        );
646
647        let bare =
648            ResourceMetadata::new(ResourceUri::new("https://example.com").map_err(Failure::Uri)?);
649        let bare_json: serde_json::Value = serde_json::to_value(&bare).map_err(Failure::Json)?;
650        assert_eq!(
651            bare_json.get("resource"),
652            Some(&serde_json::json!("https://example.com"))
653        );
654        for absent in ["authorization_servers", "scopes_supported", "resource_name"] {
655            assert!(
656                bare_json.get(absent).is_none(),
657                "RFC 9728 3.2 requires {absent} be omitted when empty, got {bare_json}"
658            );
659        }
660        // `bearer_methods_supported` is the exception: `new` defaults it to
661        // `["header"]` because this crate reads the token from the
662        // `Authorization` header and nowhere else, so it is never empty and
663        // always published.
664        assert_eq!(
665            bare_json
666                .get("bearer_methods_supported")
667                .and_then(first_element),
668            Some(serde_json::json!("header"))
669        );
670        Ok(())
671    }
672}