headless_lms_models/library/oauth/
mcp.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum McpScope {
22 MaterialRead,
24 MaterialStage,
26 MaterialPublish,
28 SettingsWrite,
30}
31
32impl McpScope {
33 pub const ALL: [McpScope; 4] = [
35 McpScope::MaterialRead,
36 McpScope::MaterialStage,
37 McpScope::MaterialPublish,
38 McpScope::SettingsWrite,
39 ];
40
41 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 pub fn all_strs() -> [&'static str; 4] {
53 Self::ALL.map(Self::as_str)
54 }
55
56 pub fn parse(scope: &str) -> Option<Self> {
58 Self::ALL.into_iter().find(|s| s.as_str() == scope)
59 }
60
61 pub fn is_required(self) -> bool {
63 self == McpScope::MaterialRead
64 }
65
66 pub fn is_checked_by_default(self) -> bool {
68 self != McpScope::SettingsWrite
69 }
70
71 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#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct McpPrincipal {
89 pub actor: CallActor,
91 pub scopes: Vec<McpScope>,
93}
94
95impl McpPrincipal {
96 pub fn has_scope(&self, scope: McpScope) -> bool {
98 self.scopes.contains(&scope)
99 }
100}
101
102pub 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}