Skip to main content

systemprompt_models/mcp/
toolset.rs

1//! Declared toolsets and per-tool access for one MCP server.
2//!
3//! A [`ToolsetRef`] names a toolset compiled into the binary (registered
4//! through the runtime's `submit_toolset!`) that a deployment hosts. A
5//! [`ToolAccess`] narrows who may see and call a tool: it is satisfied when the
6//! caller holds any listed scope, any listed role, or any listed group.
7//!
8//! Access resolves most specific first: a tool's own `access`, then the
9//! `access` of the toolset that serves it, then the server's `oauth.scopes`
10//! (already enforced for every request by RBAC). [`ResolvedAccess`] is that
11//! decision, and [`ResolvedAccess::permits`] judges one caller against it.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use serde::{Deserialize, Serialize};
17
18use super::deployment::{Deployment, McpServerType};
19use crate::auth::{AuthenticatedUser, Permission};
20use crate::errors::ServicesValidationError;
21
22/// A toolset a deployment hosts, by the id its factory registers.
23#[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/// Who may see and call a tool; any one match grants access.
37#[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/// Where a tool's effective access came from.
74#[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}