Skip to main content

headless_lms_models/library/oauth/
mcp.rs

1//! OAuth scopes and access-token validation for the MCP server at `/api/v0/mcp`.
2//!
3//! MCP tokens are Bearer tokens issued to Client ID Metadata Document clients, bound by RFC 8707
4//! to the MCP resource URL. A token's rights are its scopes intersected with what its grant still
5//! allows; the caller intersects them with the user's live roles through `authorize`.
6
7use secrecy::SecretString;
8
9use crate::{
10    agent_changeset_calls::CallActor,
11    library::oauth::{
12        bearer::{BearerTokenError, resolve_bearer_token},
13        is_bound_to,
14    },
15    oauth_user_client_scopes::OAuthUserClientScopes,
16    prelude::*,
17};
18
19/// A capability an MCP token can carry.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum McpScope {
22    /// All read tools. Every MCP grant includes it.
23    MaterialRead,
24    /// Staging changes; risky changes always wait for the user on the review page.
25    MaterialStage,
26    /// Publishing staged routine changes without the review page.
27    MaterialPublish,
28    /// The course settings tool.
29    SettingsWrite,
30}
31
32impl McpScope {
33    /// Every scope.
34    pub const ALL: [McpScope; 4] = [
35        McpScope::MaterialRead,
36        McpScope::MaterialStage,
37        McpScope::MaterialPublish,
38        McpScope::SettingsWrite,
39    ];
40
41    /// The scope as it appears in OAuth requests and tokens.
42    pub fn as_str(self) -> &'static str {
43        match self {
44            McpScope::MaterialRead => "material:read",
45            McpScope::MaterialStage => "material:stage",
46            McpScope::MaterialPublish => "material:publish",
47            McpScope::SettingsWrite => "settings:write",
48        }
49    }
50
51    /// [`Self::ALL`] as OAuth scope strings.
52    pub fn all_strs() -> [&'static str; 4] {
53        Self::ALL.map(Self::as_str)
54    }
55
56    /// The scope `scope` names; `None` for anything else.
57    pub fn parse(scope: &str) -> Option<Self> {
58        Self::ALL.into_iter().find(|s| s.as_str() == scope)
59    }
60
61    /// The consent screen does not let the user uncheck it.
62    pub fn is_required(self) -> bool {
63        self == McpScope::MaterialRead
64    }
65
66    /// Whether the consent screen's checkbox starts checked.
67    pub fn is_checked_by_default(self) -> bool {
68        self != McpScope::SettingsWrite
69    }
70
71    /// Whether the scopes ask for MCP access without `material:read`, which every MCP grant
72    /// includes.
73    pub fn lacks_required<'a>(scopes: impl IntoIterator<Item = &'a str>) -> bool {
74        let mut has_mcp_scope = false;
75        for scope in scopes {
76            match Self::parse(scope) {
77                Some(McpScope::MaterialRead) => return false,
78                Some(_) => has_mcp_scope = true,
79                None => {}
80            }
81        }
82        has_mcp_scope
83    }
84}
85
86/// Who an MCP request acts for, from a validated access token.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct McpPrincipal {
89    /// The user, client and grant the token was issued to; agent writes are recorded under it.
90    pub actor: CallActor,
91    /// The token's scopes still allowed by its grant.
92    pub scopes: Vec<McpScope>,
93}
94
95impl McpPrincipal {
96    /// Whether the token carries `scope`.
97    pub fn has_scope(&self, scope: McpScope) -> bool {
98        self.scopes.contains(&scope)
99    }
100}
101
102/// Validates a Bearer access token for the MCP server and resolves who it acts for. Records the
103/// grant as used.
104pub async fn authenticate_mcp_access_token(
105    conn: &mut PgConnection,
106    token: &str,
107    token_hmac_key: &SecretString,
108    mcp_resource_url: &str,
109) -> Result<McpPrincipal, BearerTokenError> {
110    let token = resolve_bearer_token(conn, token, token_hmac_key).await?;
111    if !is_bound_to(token.audience.as_deref(), mcp_resource_url) {
112        return Err(BearerTokenError::Invalid(
113            "The access token was not issued for this MCP server.",
114        ));
115    }
116    if !token.is_metadata_document_client {
117        return Err(BearerTokenError::Invalid(
118            "The access token's client may not use the MCP server.",
119        ));
120    }
121    let scopes = token
122        .scopes
123        .iter()
124        .filter_map(|scope| McpScope::parse(scope))
125        .collect();
126    OAuthUserClientScopes::mark_grant_used(conn, token.grant.id, token.grant.last_used_at)
127        .await
128        .map_err(BearerTokenError::Database)?;
129    Ok(McpPrincipal {
130        actor: CallActor {
131            user_id: token.user.id,
132            oauth_client_id: token.client_id,
133            oauth_grant_id: token.grant.id,
134            client_name: token.client_name,
135        },
136        scopes,
137    })
138}