protocol-droid 0.3.0

A Model Context Protocol toolkit that speaks every published spec revision, with one canonical IR behind many per-revision codecs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
//! The canonical resource identifier and the document that publishes it.
//!
//! RFC 9728 ("OAuth 2.0 Protected Resource Metadata") is what makes an MCP
//! server discoverable as an OAuth resource server: a server publishes a
//! metadata document naming its authorization servers, and a client that gets
//! a `401` reads it to find out where to get a token.
//!
//! That document's `resource` field *is* the audience. RFC 8707 resource
//! indicators bind a token to it, and this crate refuses a token whose audience
//! is not exactly this string. So [`ResourceUri`] is a value object rather than
//! a convenience: two spellings of the same server that compare unequal is a
//! token rejected for a reason nobody can see.
//!
//! # Where this lives
//!
//! `domain/`, because the canonicalisation rules are the security-relevant
//! part and an adapter should not get to choose them. Which *revisions* require
//! any of this is [`authorization_model`](super::authorization::authorization_model).

use std::sync::Arc;

use core::fmt;
use core::str::FromStr;

/// The well-known path an MCP server publishes its metadata at.
///
/// RFC 9728 §3.1: the metadata for a resource at the origin root is
/// `/.well-known/oauth-protected-resource`, and the metadata for a resource at
/// some other path is `/.well-known/oauth-protected-resource` with that path
/// *appended*. So a server mounted at `/mcp` publishes at
/// `/.well-known/oauth-protected-resource/mcp` — see
/// [`ResourceUri::metadata_path`].
pub const WELL_KNOWN: &str = "/.well-known/oauth-protected-resource";

/// How a bearer token reaches the server.
///
/// RFC 9728 §2: `bearer_methods_supported` names the mechanisms this resource
/// accepts. Only the `header` mechanism is defined by the RFC, and it is the
/// only one this crate reads a token from — the alternative in the spec's
/// discussion is the `Authorization` request body parameter, which MCP's
/// transports have no place for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum BearerMethod {
    /// RFC 6750 §2.1, the `Authorization: Bearer` header.
    ///
    /// The wire spelling is lower case because that is what the RFC's
    /// `bearer_methods_supported` array contains and what a client's parser
    /// compares against.
    #[default]
    Header,
}

impl BearerMethod {
    /// The value as it appears in a `bearer_methods_supported` array.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Header => "header",
        }
    }
}

/// The canonical resource identifier for an MCP server: the `resource` field,
/// and the audience a token must carry.
///
/// # Canonicalisation
///
/// Two rules, both from RFC 9728 §2 and RFC 8707 §2, and both of which a
/// hand-rolled string comparison would get wrong:
///
/// - **No trailing slash.** `https://example.com/mcp` and
///   `https://example.com/mcp/` are the same resource to a human and two
///   different strings to an `aud` comparison, so the slash is dropped once.
///   A bare origin (`https://example.com`) keeps none.
/// - **No default port.** `https://example.com:443/mcp` and
///   `https://example.com/mcp` are the same resource, so `443` and `80` are
///   dropped for their schemes.
///
/// Scheme and host are **not** case-folded beyond parsing the scheme, because
/// the host part is not something this crate can safely normalise: IDNA
/// punycode and case-sensitive comparison are a much larger problem than
/// authorization needs, and a wrong fold is a wrong audience. What is folded is
/// the scheme, which RFC 3986 §3.1 defines as case-insensitive.
///
/// # Examples
///
/// ```
/// use protocol_droid::domain::mcp::ResourceUri;
///
/// // The trailing slash is dropped, so both spellings name one resource.
/// assert_eq!(
///     ResourceUri::new("https://example.com/mcp/").unwrap().as_str(),
///     "https://example.com/mcp",
/// );
/// assert_eq!(
///     ResourceUri::new("https://example.com/mcp").unwrap(),
///     ResourceUri::new("https://example.com/mcp/").unwrap(),
/// );
///
/// // A default port is dropped too.
/// assert_eq!(
///     ResourceUri::new("HTTPS://example.com:443/mcp").unwrap().as_str(),
///     "https://example.com/mcp",
/// );
///
/// // A non-default port is part of the resource and is kept.
/// assert_eq!(
///     ResourceUri::new("https://example.com:8443/mcp").unwrap().as_str(),
///     "https://example.com:8443/mcp",
/// );
/// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
/// ```
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResourceUri(Arc<str>);

impl ResourceUri {
    /// Canonicalises a resource identifier.
    ///
    /// The inherent twin of [`FromStr`], so a caller reading this module does
    /// not have to know that the parsing rule lives in a trait impl. Both do
    /// the same thing; `new` exists because the `parse` in a doctest is noise
    /// when the value came from a config file rather than from user input.
    ///
    /// # Errors
    ///
    /// The same as [`ResourceUri::from_str`].
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::ResourceUri;
    ///
    /// assert_eq!(
    ///     ResourceUri::new("https://example.com/mcp/")?.as_str(),
    ///     "https://example.com/mcp",
    /// );
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    pub fn new(value: &str) -> Result<Self, ResourceUriError> {
        value.parse()
    }

    /// The canonical string, which is also the `resource` field and the
    /// expected `aud`.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Builds a canonical identifier from a base origin and a mounted path.
    ///
    /// The join is done here rather than by the caller because the two halves
    /// each arrive from somewhere that gets slash handling wrong: a config file
    /// writes a trailing slash on the origin, and the mount point is an
    /// [`Endpoint`] that already requires a leading one.
    ///
    /// # Errors
    ///
    /// Whatever [`Self::new`] returns for the joined string.
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::ResourceUri;
    ///
    /// let uri = ResourceUri::from_parts("https://example.com/", "/mcp")?;
    /// assert_eq!(uri.as_str(), "https://example.com/mcp");
    ///
    /// // An origin with no path still produces a usable identifier.
    /// let root = ResourceUri::from_parts("https://example.com", "")?;
    /// assert_eq!(root.as_str(), "https://example.com");
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    pub fn from_parts(origin: &str, path: &str) -> Result<Self, ResourceUriError> {
        let base = origin.trim_end_matches('/');
        // A path of `/` means the resource *is* the origin; anything else is
        // appended to the origin with exactly one slash between them.
        let trimmed = path.trim_matches('/');
        let joined = if trimmed.is_empty() {
            base.to_owned()
        } else {
            format!("{base}/{trimmed}")
        };
        Self::new(&joined)
    }

    /// The RFC 9728 path this resource's metadata is published at.
    ///
    /// The resource's own path appended to [`WELL_KNOWN`], which is the
    /// path-inserted form from RFC 9728 §3.1. A resource at the origin root
    /// publishes at exactly [`WELL_KNOWN`].
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::ResourceUri;
    ///
    /// assert_eq!(
    ///     ResourceUri::new("https://example.com/mcp").unwrap().metadata_path(),
    ///     "/.well-known/oauth-protected-resource/mcp",
    /// );
    /// assert_eq!(
    ///     ResourceUri::new("https://example.com").unwrap().metadata_path(),
    ///     "/.well-known/oauth-protected-resource",
    /// );
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    #[must_use]
    pub fn metadata_path(&self) -> String {
        let Some(path) = self.path() else {
            return WELL_KNOWN.to_owned();
        };
        format!("{WELL_KNOWN}{path}")
    }

    /// The absolute URL a `WWW-Authenticate` challenge points at.
    ///
    /// # Errors
    ///
    /// [`ResourceUriError::NoOrigin`] when this identifier has no origin, which
    /// [`Self::new`] rules out — so this cannot fire from a `ResourceUri` that
    /// was built through the constructor, and exists only so the return type is
    /// honest about the fallback rather than panicking.
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::ResourceUri;
    ///
    /// let uri = ResourceUri::new("https://example.com/mcp")?;
    /// assert_eq!(
    ///     uri.metadata_url()?,
    ///     "https://example.com/.well-known/oauth-protected-resource/mcp",
    /// );
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    pub fn metadata_url(&self) -> Result<String, ResourceUriError> {
        let origin = self.origin().ok_or(ResourceUriError::NoOrigin)?;
        Ok(format!("{origin}{}", self.metadata_path()))
    }

    /// The origin — scheme, host and any non-default port — with no path.
    fn origin(&self) -> Option<&str> {
        let after_scheme = self.0.find("://")? + 3;
        let end = self.0[after_scheme..]
            .find('/')
            .map_or(self.0.len(), |offset| after_scheme + offset);
        Some(&self.0[..end])
    }

    /// The path component, without the origin and without a trailing slash.
    fn path(&self) -> Option<&str> {
        let after_scheme = self.0.find("://")? + 3;
        let slash = self.0[after_scheme..].find('/')? + after_scheme;
        Some(&self.0[slash..])
    }
}

impl FromStr for ResourceUri {
    type Err = ResourceUriError;

    /// # Errors
    ///
    /// [`ResourceUriError::Empty`] for a blank value, and
    /// [`ResourceUriError::NotAbsolute`] when there is no `scheme://`. RFC 9728
    /// §2 requires `resource` to be an absolute URI, so a relative one is not
    /// a resource identifier and cannot be compared against an `aud`.
    ///
    /// A trailing slash and a default port are canonicalised rather than
    /// refused — see the type docs for why that is the right call.
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::ResourceUri;
    ///
    /// assert!(ResourceUri::new("example.com/mcp").is_err());
    /// assert!(ResourceUri::new("  ").is_err());
    /// assert!(ResourceUri::new("https://example.com/mcp").is_ok());
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            return Err(ResourceUriError::Empty);
        }

        let Some(scheme_end) = trimmed.find("://") else {
            return Err(ResourceUriError::NotAbsolute);
        };
        let scheme = &trimmed[..scheme_end];
        if scheme.is_empty()
            || !scheme
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
        {
            return Err(ResourceUriError::NotAbsolute);
        }

        // RFC 3986 §3.1: the scheme is case-insensitive. Only the scheme —
        // see the type docs on why the host is not folded.
        let mut canonical = String::with_capacity(trimmed.len());
        canonical.push_str(&scheme.to_ascii_lowercase());
        canonical.push_str("://");

        let rest = &trimmed[scheme_end + 3..];
        let (authority, path) = rest
            .find('/')
            .map_or((rest, ""), |slash| (&rest[..slash], &rest[slash..]));
        if authority.is_empty() {
            return Err(ResourceUriError::NoAuthority);
        }
        canonical.push_str(&drop_default_port(authority, scheme));
        // Exactly one trailing slash is dropped, and only from the end of a
        // non-empty path, so `/mcp//` keeps the second slash rather than
        // quietly collapsing a path that is genuinely different.
        let path = path.strip_suffix('/').unwrap_or(path);
        canonical.push_str(path);

        Ok(Self(Arc::from(canonical.as_str())))
    }
}

/// `authority` without its port when the port is the scheme's default.
///
/// Compares the scheme case-insensitively because the caller passes the raw
/// scheme, not the canonicalised one, and `HTTPS://example.com:443` has to
/// fold the same way `https://example.com:443` does.
///
/// The port separator is *not* always the last colon. In an IPv6 literal every
/// group is separated by one — `[::1]:8443` has three — so a `rsplit(':')` cuts
/// the host in half and then compares whatever followed to the default port.
/// The closing bracket is what marks the end of the host in that case, and
/// getting this wrong silently drops or keeps the port on an address nobody
/// writes as a hostname.
fn drop_default_port(authority: &str, scheme: &str) -> String {
    let default = match scheme.to_ascii_lowercase().as_str() {
        "https" => Some("443"),
        "http" => Some("80"),
        _ => None,
    };

    // For a bracketed literal the host is everything through the `]`, and the
    // separator is the first `:` after it. `find` on the tail returns an offset
    // *into the tail*, so the addition that steps past the bracket has to happen
    // inside the closure — chained after it, `close` is gone and only the
    // wrong-index number is left. For anything else the last `:` separates.
    let separator = if authority.starts_with('[') {
        authority.find(']').and_then(|close| {
            authority[close + 1..]
                .find(':')
                .map(|in_tail| close + 1 + in_tail)
        })
    } else {
        authority.rfind(':')
    };

    let Some(separator) = separator else {
        return authority.to_owned();
    };
    debug_assert!(authority.is_char_boundary(separator));
    let (host, port) = authority.split_at(separator);
    let port = &port[1..];

    if default == Some(port) {
        return host.to_owned();
    }
    authority.to_owned()
}

impl TryFrom<&str> for ResourceUri {
    type Error = ResourceUriError;

    /// # Errors
    ///
    /// The same as [`ResourceUri::new`].
    fn try_from(value: &str) -> Result<Self, Self::Error> {
        value.parse()
    }
}

impl fmt::Display for ResourceUri {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// Why a string is not a usable resource identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ResourceUriError {
    /// The value was blank.
    #[error("the resource identifier is empty")]
    Empty,

    /// There was no `scheme://`, or the scheme was not a legal one.
    ///
    /// RFC 9728 §2 requires an absolute URI, so this is a refusal rather than
    /// something to guess at: a relative identifier cannot be compared against
    /// a token's `aud`, which is the only thing it is for.
    #[error("the resource identifier is not an absolute URI with an http(s) scheme")]
    NotAbsolute,

    /// The authority was empty between `://` and the first `/`.
    #[error("the resource identifier names no host")]
    NoAuthority,

    /// The derived metadata path was not usable.
    ///
    /// Unreachable through [`ResourceUri::new`] — the constructor refuses
    /// anything without an authority — so it exists to give
    /// [`ResourceUri::metadata_url`] an error type rather than a panic.
    #[error("the resource identifier has no origin to publish metadata under")]
    NoOrigin,
}

/// An RFC 9728 protected-resource metadata document.
///
/// What an MCP server publishes to tell a client which authorization servers can
/// issue tokens for it, and which scopes those tokens should carry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceMetadata {
    /// This server's canonical resource identifier. Required by RFC 9728 §2.
    resource: ResourceUri,

    /// Issuer URLs of the authorization servers that can issue tokens for this
    /// resource.
    ///
    /// A `Vec` rather than a set because RFC 9728 §2 defines an array and the
    /// order is not significant, but deduplication is still the server's
    /// decision — publishing the same issuer twice is odd rather than wrong,
    /// and refusing to publish it would be this type being clever with
    /// somebody's configuration.
    authorization_servers: Vec<Arc<str>>,

    /// Scopes a token for this resource should carry.
    scopes_supported: Vec<Arc<str>>,

    /// How a bearer token may be presented. Always [`BearerMethod::Header`].
    bearer_methods_supported: Vec<BearerMethod>,

    /// Human-readable name for the resource, from RFC 9728 §2.
    resource_name: Option<Arc<str>>,
}

impl ResourceMetadata {
    /// A document for `resource`, with the mandatory `resource` field and
    /// nothing optional.
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
    ///
    /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
    /// assert_eq!(metadata.resource().as_str(), "https://example.com/mcp");
    /// assert!(metadata.authorization_servers().is_empty());
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    #[must_use]
    pub fn new(resource: ResourceUri) -> Self {
        Self {
            resource,
            authorization_servers: Vec::new(),
            scopes_supported: Vec::new(),
            bearer_methods_supported: vec![BearerMethod::Header],
            resource_name: None,
        }
    }

    /// Names an authorization server that can issue tokens for this resource.
    #[must_use]
    pub fn with_authorization_server(mut self, issuer: impl Into<Arc<str>>) -> Self {
        self.authorization_servers.push(issuer.into());
        self
    }

    /// Declares a scope a token may carry.
    #[must_use]
    pub fn with_scope(mut self, scope: impl Into<Arc<str>>) -> Self {
        self.scopes_supported.push(scope.into());
        self
    }

    /// Sets the human-readable name.
    #[must_use]
    pub fn with_name(mut self, name: impl Into<Arc<str>>) -> Self {
        self.resource_name = Some(name.into());
        self
    }

    /// The canonical resource identifier, and the audience a token must carry.
    #[must_use]
    pub const fn resource(&self) -> &ResourceUri {
        &self.resource
    }

    /// The authorization servers this resource names.
    #[must_use]
    pub fn authorization_servers(&self) -> &[Arc<str>] {
        &self.authorization_servers
    }

    /// The scopes this resource declares.
    #[must_use]
    pub fn scopes_supported(&self) -> &[Arc<str>] {
        &self.scopes_supported
    }

    /// How a bearer token may be presented.
    #[must_use]
    pub fn bearer_methods_supported(&self) -> &[BearerMethod] {
        &self.bearer_methods_supported
    }

    /// The `WWW-Authenticate` challenge for a request that was refused.
    ///
    /// RFC 9728 §5.1: the challenge carries `resource_metadata` so the client
    /// knows where to discover the authorization server, which is the whole
    /// reason a server publishes the document.
    ///
    /// # Errors
    ///
    /// [`ResourceUriError::NoOrigin`] when the resource identifier has no
    /// origin, which the constructor rules out.
    ///
    /// # Examples
    ///
    /// ```
    /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
    ///
    /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
    /// assert_eq!(
    ///     metadata.challenge()?,
    ///     "Bearer resource_metadata=\"https://example.com/.well-known/oauth-protected-resource/mcp\"",
    /// );
    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
    /// ```
    pub fn challenge(&self) -> Result<String, ResourceUriError> {
        Ok(format!(
            "Bearer resource_metadata=\"{}\"",
            self.resource.metadata_url()?
        ))
    }
}

#[cfg(test)]
#[path = "resource_metadata/tests.rs"]
mod tests;