systemprompt_models/mcp/
toolset.rs1use serde::{Deserialize, Serialize};
17
18use super::deployment::{Deployment, McpServerType};
19use crate::auth::{AuthenticatedUser, Permission};
20use crate::errors::ServicesValidationError;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ToolsetRef {
25 pub id: String,
26 #[serde(default = "default_enabled")]
27 pub enabled: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub access: Option<ToolAccess>,
30}
31
32const fn default_enabled() -> bool {
33 true
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38pub struct ToolAccess {
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub scopes: Vec<Permission>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub roles: Vec<String>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub groups: Vec<String>,
45}
46
47impl ToolAccess {
48 #[must_use]
49 pub const fn is_empty(&self) -> bool {
50 self.scopes.is_empty() && self.roles.is_empty() && self.groups.is_empty()
51 }
52
53 #[must_use]
54 pub const fn needs_groups(&self) -> bool {
55 !self.groups.is_empty()
56 }
57
58 #[must_use]
59 pub fn permits(&self, user: Option<&AuthenticatedUser>, groups: &[String]) -> bool {
60 let Some(user) = user else {
61 return false;
62 };
63 let scope_match = self
64 .scopes
65 .iter()
66 .any(|required| user.permissions.iter().any(|held| held.implies(required)));
67 let role_match = self.roles.iter().any(|role| user.roles.contains(role));
68 let group_match = self.groups.iter().any(|group| groups.contains(group));
69 scope_match || role_match || group_match
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ResolvedAccess<'a> {
76 Tool(&'a ToolAccess),
77 Toolset(&'a ToolAccess),
78 Server,
79}
80
81impl<'a> ResolvedAccess<'a> {
82 #[must_use]
83 pub const fn resolve(tool: Option<&'a ToolAccess>, toolset: Option<&'a ToolAccess>) -> Self {
84 match (tool, toolset) {
85 (Some(access), _) => Self::Tool(access),
86 (None, Some(access)) => Self::Toolset(access),
87 (None, None) => Self::Server,
88 }
89 }
90
91 #[must_use]
92 pub const fn access(&self) -> Option<&'a ToolAccess> {
93 match *self {
94 Self::Tool(access) | Self::Toolset(access) => Some(access),
95 Self::Server => None,
96 }
97 }
98
99 #[must_use]
100 pub fn needs_groups(&self) -> bool {
101 self.access().is_some_and(ToolAccess::needs_groups)
102 }
103
104 #[must_use]
105 pub fn permits(&self, user: Option<&AuthenticatedUser>, groups: &[String]) -> bool {
106 self.access()
107 .is_none_or(|access| access.permits(user, groups))
108 }
109}
110
111impl Deployment {
112 pub(super) fn validate_toolsets(&self, name: &str) -> Result<(), ServicesValidationError> {
113 if self.toolsets.is_empty() {
114 return Ok(());
115 }
116 if !matches!(self.server_type, McpServerType::Internal) {
117 return Err(ServicesValidationError::invalid_field(format!(
118 "MCP server '{name}': toolsets are only valid on internal servers."
119 )));
120 }
121 let mut seen = std::collections::HashSet::new();
122 for toolset in &self.toolsets {
123 if toolset.id.trim().is_empty() {
124 return Err(ServicesValidationError::invalid_field(format!(
125 "MCP server '{name}': a toolset entry has an empty id."
126 )));
127 }
128 if !seen.insert(toolset.id.as_str()) {
129 return Err(ServicesValidationError::invalid_field(format!(
130 "MCP server '{name}': toolset '{}' is listed twice.",
131 toolset.id
132 )));
133 }
134 if toolset.access.as_ref().is_some_and(ToolAccess::is_empty) {
135 return Err(ServicesValidationError::invalid_field(format!(
136 "MCP server '{name}': toolset '{}' declares an empty access block; list \
137 scopes, roles or groups, or remove it.",
138 toolset.id
139 )));
140 }
141 }
142 for (tool, meta) in &self.tools {
143 if meta.access.as_ref().is_some_and(ToolAccess::is_empty) {
144 return Err(ServicesValidationError::invalid_field(format!(
145 "MCP server '{name}': tool '{tool}' declares an empty access block; list \
146 scopes, roles or groups, or remove it."
147 )));
148 }
149 }
150 Ok(())
151 }
152
153 pub fn enabled_toolsets(&self) -> impl Iterator<Item = &ToolsetRef> {
154 self.toolsets.iter().filter(|toolset| toolset.enabled)
155 }
156}