Skip to content
Clone

feat(mcp): add lib/mcp crate (Model Context Protocol server with auth, access_key, ip_filter, tools, router, service)

zhenyi committed
parent 34204f3 commit 5562e7e Browse files

11 files changed

+1377 −0

File tree 11
Added lib/mcp/access_key.rs View lib/mcp/access_key.rs
@@ -0,0 +1,83 @@
1use db::conn::sea_orm::{EntityTrait, QueryFilter};
2use db::user::user_access_keys::{self, AccessKeyScope as DbScope};
3use sha2::{Digest, Sha256};
4use uuid::Uuid;
5
6use crate::error::{AuthError, McpError, ct_eq};
7
8pub fn token_last_eight(raw: &str) -> String {
9 raw.chars()
10 .rev()
11 .take(8)
12 .collect::<String>()
13 .chars()
14 .rev()
15 .collect()
16}
17
18pub fn compute_token_hash(raw: &str, salt: &str) -> String {
19 let mut h = Sha256::new();
20 h.update(raw.as_bytes());
21 h.update(salt.as_bytes());
22 let out = h.finalize();
23 let mut s = String::with_capacity(out.len() * 2);
24 for b in out {
25 s.push_str(&format!("{:02x}", b));
26 }
27 s
28}
29
30pub async fn verify_access_key(
31 db: &db::conn::Db,
32 raw_token: &str,
33) -> Result<(Uuid, DbScope), McpError> {
34 if raw_token.len() < 8 {
35 return Err(AuthError::InvalidCredentials.into());
36 }
37 let last_eight = token_last_eight(raw_token);
38 let conn = db.reader();
39 let rows = user_access_keys::Entity::find()
40 .filter(user_access_keys::COLUMN.token_last_eight.eq(&last_eight))
41 .all(&conn)
42 .await
43 .map_err(|e| McpError::Db(db::conn::DbError::PrimaryPool(e)))?;
44 for row in rows {
45 let candidate = compute_token_hash(raw_token, &row.token_salt);
46 if ct_eq(candidate.as_bytes(), row.token_hash.as_bytes()) {
47 return Ok((row.user_id, row.scope));
48 }
49 }
50 Err(AuthError::InvalidCredentials.into())
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56
57 #[test]
58 fn compute_token_hash_format() {
59 let raw = "Tm90LWFjdHVhbC1zZWNyZXRfdXNlZF9vbmx5X2ZvcjpU";
60 let salt = "abcdef0123456789";
61 let h = compute_token_hash(raw, salt);
62 let mut hasher = Sha256::new();
63 hasher.update(raw.as_bytes());
64 hasher.update(salt.as_bytes());
65 let expected: String = hasher
66 .finalize()
67 .iter()
68 .map(|b| format!("{:02x}", b))
69 .collect();
70 assert_eq!(h, expected);
71 assert_eq!(h.len(), 64);
72 assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
73 }
74
75 #[test]
76 fn token_last_eight_takes_suffix() {
77 assert_eq!(token_last_eight(""), "");
78 assert_eq!(token_last_eight("ab"), "ab");
79 assert_eq!(token_last_eight("abcdEFGH"), "abcdEFGH");
80 assert_eq!(token_last_eight("xxxxxxxxxxxxabcdEFGH"), "abcdEFGH");
81 assert_eq!(token_last_eight("0123456789ABCDEFGH"), "ABCDEFGH");
82 }
83}
No newline at end of file
Added lib/mcp/auth.rs View lib/mcp/auth.rs
@@ -0,0 +1,288 @@
1//! supplied via `X-Mcp-Internal-Key` or `Authorization: Bearer <secret>`.
2use std::net::{IpAddr, SocketAddr};
3
4use axum::extract::{ConnectInfo, Request, State};
5use axum::http::header::AUTHORIZATION;
6use axum::http::{HeaderMap, StatusCode};
7use axum::middleware::Next;
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use serde_json::json;
11use uuid::Uuid;
12
13use db::user::user_access_keys::AccessKeyScope as DbScope;
14
15use crate::access_key;
16use crate::error::{AuthError, McpError, ct_eq};
17use crate::service::McpService;
18
19#[derive(Debug, Clone)]
20pub enum McpPrincipal {
21 External { user_id: Uuid, scope: DbScope },
22 Internal,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum McpTrack {
27 External,
28 Internal,
29}
30
31impl McpPrincipal {
32 pub fn track(&self) -> McpTrack {
33 match self {
34 McpPrincipal::External { .. } => McpTrack::External,
35 McpPrincipal::Internal => McpTrack::Internal,
36 }
37 }
38
39 /// Per-tool scope gate. Internal callers are always allowed.
40 /// External callers must satisfy `AccessKeyScope::allows(wants_write)`.
41 pub fn tool_allowed(&self, wants_write: bool) -> bool {
42 match self {
43 McpPrincipal::Internal => true,
44 McpPrincipal::External { scope, .. } => scope.allows(wants_write),
45 }
46 }
47
48 pub fn actor_uuid(&self, svc: &McpService) -> Result<Uuid, McpError> {
49 match self {
50 McpPrincipal::External { user_id, .. } => Ok(*user_id),
51 McpPrincipal::Internal => svc.system_user_uuid(),
52 }
53 }
54}
55
56fn source_ip(headers: &HeaderMap, peer: SocketAddr, trust_proxy: bool) -> IpAddr {
57 if trust_proxy {
58 if let Some(real) = headers
59 .get("x-real-ip")
60 .and_then(|v| v.to_str().ok())
61 .and_then(|s| s.trim().parse().ok())
62 {
63 return real;
64 }
65 if let Some(xff) = headers
66 .get("x-forwarded-for")
67 .and_then(|v| v.to_str().ok())
68 .and_then(|s| s.split(',').next())
69 .and_then(|s| s.trim().parse().ok())
70 {
71 return xff;
72 }
73 }
74 peer.ip()
75}
76
77fn read_presented(headers: &HeaderMap) -> Option<&str> {
78 if let Some(hk) = headers.get("x-mcp-internal-key").and_then(|v| v.to_str().ok()) {
79 if !hk.is_empty() {
80 return Some(hk);
81 }
82 }
83 if let Some(auth) = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()) {
84 if let Some(rest) = auth.strip_prefix("Bearer ") {
85 if !rest.is_empty() {
86 return Some(rest);
87 }
88 }
89 }
90 None
91}
92
93fn check_internal_secret(svc: &McpService, headers: &HeaderMap) -> Result<McpPrincipal, McpError> {
94 let configured = svc
95 .settings
96 .internal_secret_key
97 .as_deref()
98 .ok_or(AuthError::InternalNotConfigured)?;
99 let presented = read_presented(headers).ok_or(AuthError::InvalidCredentials)?;
100 if ct_eq(presented.as_bytes(), configured.as_bytes()) {
101 Ok(McpPrincipal::Internal)
102 } else {
103 Err(AuthError::InvalidCredentials.into())
104 }
105}
106
107async fn check_external_key(svc: &McpService, headers: &HeaderMap) -> Result<McpPrincipal, McpError> {
108 let presented = read_presented(headers).ok_or(AuthError::InvalidCredentials)?;
109 let (user_id, scope) = access_key::verify_access_key(&svc.db, presented).await?;
110 Ok(McpPrincipal::External { user_id, scope })
111}
112
113fn unauthorized() -> Response {
114 (StatusCode::UNAUTHORIZED, Json(json!({"error": "mcp unauthorized"}))).into_response()
115}
116
117fn forbidden() -> Response {
118 (StatusCode::FORBIDDEN, Json(json!({"error": "mcp ip not allowed"}))).into_response()
119}
120
121fn internal_error() -> Response {
122 (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "mcp internal"}))).into_response()
123}
124
125pub async fn dual_track_auth(
126 State(svc): State<McpService>,
127 ConnectInfo(peer): ConnectInfo<SocketAddr>,
128 mut req: Request,
129 next: Next,
130) -> Response {
131 let ip = source_ip(req.headers(), peer, svc.settings.trust_proxy);
132 let result = if svc.ip_filter.contains(ip) {
133 check_internal_secret(&svc, req.headers())
134 } else {
135 check_external_key(&svc, req.headers()).await
136 };
137 match result {
138 Ok(principal) => {
139 req.extensions_mut().insert(principal);
140 next.run(req).await
141 }
142 Err(McpError::Auth(AuthError::InvalidCredentials))
143 | Err(McpError::InternalNotConfigured) => unauthorized(),
144 Err(McpError::Auth(AuthError::IpNotAllowed)) => forbidden(),
145 Err(e @ McpError::Auth(AuthError::InsufficientScope(_))) => {
146 tracing::warn!(error = %e, "mcp auth: scope");
147 unauthorized()
148 }
149 Err(McpError::Db(_)) => {
150 tracing::error!("mcp auth: database error");
151 internal_error()
152 }
153 Err(e) => {
154 tracing::warn!(error = %e, "mcp auth: internal");
155 internal_error()
156 }
157 }
158}
159
160pub async fn internal_only_auth(
161 State(svc): State<McpService>,
162 ConnectInfo(peer): ConnectInfo<SocketAddr>,
163 mut req: Request,
164 next: Next,
165) -> Response {
166 let ip = source_ip(req.headers(), peer, svc.settings.trust_proxy);
167 if !svc.ip_filter.contains(ip) {
168 return forbidden();
169 }
170 match check_internal_secret(&svc, req.headers()) {
171 Ok(principal) => {
172 req.extensions_mut().insert(principal);
173 next.run(req).await
174 }
175 Err(_) => unauthorized(),
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::service::McpService;
183 use axum::http::HeaderValue;
184
185 fn make_svc() -> McpService {
186 unreachable!()
187 }
188
189 #[test]
190 fn source_ip_prefers_peer_by_default() {
191 let peer: SocketAddr = "10.0.0.1:5555".parse().unwrap();
192 let mut headers = HeaderMap::new();
193 headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.7"));
194 headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.8"));
195 assert_eq!(
196 source_ip(&headers, peer, false),
197 IpAddr::from([10, 0, 0, 1])
198 );
199 }
200
201 #[test]
202 fn source_ip_uses_proxy_headers_when_enabled() {
203 let peer: SocketAddr = "127.0.0.1:5555".parse().unwrap();
204 let mut headers = HeaderMap::new();
205 headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1"));
206 assert_eq!(
207 source_ip(&headers, peer, true),
208 IpAddr::from([10, 0, 0, 1])
209 );
210 }
211
212 #[test]
213 fn source_ip_xff_takes_leftmost_when_enabled() {
214 let peer: SocketAddr = "127.0.0.1:5555".parse().unwrap();
215 let mut headers = HeaderMap::new();
216 headers.insert(
217 "x-forwarded-for",
218 HeaderValue::from_static("10.0.0.5, 10.0.0.6, 10.0.0.7"),
219 );
220 assert_eq!(
221 source_ip(&headers, peer, true),
222 IpAddr::from([10, 0, 0, 5])
223 );
224 }
225
226 #[test]
227 fn source_ip_falls_back_to_peer_when_proxy_unparseable() {
228 let peer: SocketAddr = "10.0.0.1:5555".parse().unwrap();
229 let mut headers = HeaderMap::new();
230 headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
231 assert_eq!(
232 source_ip(&headers, peer, true),
233 IpAddr::from([10, 0, 0, 1])
234 );
235 }
236
237 #[test]
238 fn read_presented_accepts_internal_header() {
239 let mut headers = HeaderMap::new();
240 headers.insert("x-mcp-internal-key", HeaderValue::from_static("shared-secret"));
241 assert_eq!(read_presented(&headers), Some("shared-secret"));
242 }
243
244 #[test]
245 fn read_presented_accepts_bearer() {
246 let mut headers = HeaderMap::new();
247 headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer some-token"));
248 assert_eq!(read_presented(&headers), Some("some-token"));
249 }
250
251 #[test]
252 fn read_presented_prefers_internal_header_over_bearer() {
253 let mut headers = HeaderMap::new();
254 headers.insert("x-mcp-internal-key", HeaderValue::from_static("internal"));
255 headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer external"));
256 assert_eq!(read_presented(&headers), Some("internal"));
257 }
258
259 #[test]
260 fn read_presented_rejects_empty_and_missing() {
261 let empty = HeaderMap::new();
262 assert_eq!(read_presented(&empty), None);
263 let mut headers = HeaderMap::new();
264 headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic abc"));
265 assert_eq!(read_presented(&headers), None);
266 }
267
268 #[test]
269 fn principal_tool_allowed_scope_gate() {
270 let external_read = McpPrincipal::External {
271 user_id: Uuid::nil(),
272 scope: DbScope::Read,
273 };
274 assert!(external_read.tool_allowed(false));
275 assert!(!external_read.tool_allowed(true));
276
277 let external_write = McpPrincipal::External {
278 user_id: Uuid::nil(),
279 scope: DbScope::Write,
280 };
281 assert!(external_write.tool_allowed(false));
282 assert!(external_write.tool_allowed(true));
283
284 let internal = McpPrincipal::Internal;
285 assert!(internal.tool_allowed(false));
286 assert!(internal.tool_allowed(true));
287 }
288}
No newline at end of file
Added lib/mcp/Cargo.toml View lib/mcp/Cargo.toml
@@ -0,0 +1,40 @@
1[package]
2name = "mcp"
3version.workspace = true
4edition.workspace = true
5authors.workspace = true
6categories.workspace = true
7keywords.workspace = true
8repository.workspace = true
9homepage.workspace = true
10
11[lib]
12path = "lib.rs"
13name = "mcp"
14
15[dependencies]
16config.workspace = true
17db.workspace = true
18repo = { path = "../repo" }
19issues.workspace = true
20project.workspace = true
21space.workspace = true
22export.workspace = true
23axum.workspace = true
24tokio.workspace = true
25serde.workspace = true
26serde_json.workspace = true
27thiserror.workspace = true
28async-trait.workspace = true
29tracing.workspace = true
30http.workspace = true
31sha2.workspace = true
32hex.workspace = true
33uuid.workspace = true
34rmcp = { version = "=3.1.2", features = ["transport-streamable-http-server"] }
35ipnet = "2.12"
36subtle = "2.6"
37tokio-util.workspace = true
38
39[dev-dependencies]
40tower = { version = "0.5", features = ["util"] }
No newline at end of file
Added lib/mcp/error.rs View lib/mcp/error.rs
@@ -0,0 +1,81 @@
1use subtle::ConstantTimeEq;
2use thiserror::Error;
3
4#[inline]
5pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
6 a.ct_eq(b).into()
7}
8
9#[derive(Debug, Error)]
10pub enum McpError {
11 #[error("mcp disabled by configuration")]
12 Disabled,
13 #[error("invalid mcp config: {0}")]
14 InvalidConfig(String),
15 #[error("mcp db error: {0}")]
16 Db(#[from] db::conn::DbError),
17 #[error("mcp auth: {0}")]
18 Auth(AuthError),
19 #[error("mcp tool `{tool}` failed: {message}")]
20 Tool { tool: String, message: String },
21 #[error("internal MCP track not configured (mcp.internal_secret_key unset)")]
22 InternalNotConfigured,
23 #[error("mcp authentication context missing from request")]
24 AuthContextMissing,
25}
26
27pub const JSONRPC_PARSE_ERROR: i64 = -32700;
28pub const JSONRPC_INVALID_REQUEST: i64 = -32600;
29pub const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
30pub const JSONRPC_INVALID_PARAMS: i64 = -32602;
31pub const JSONRPC_INTERNAL_ERROR: i64 = -32603;
32pub const MCP_TOOL_ERROR: i64 = -32000;
33pub const MCP_UNAUTHORIZED: i64 = -32001;
34pub const MCP_IP_NOT_ALLOWED: i64 = -32002;
35
36#[derive(Debug, Error)]
37pub enum AuthError {
38 #[error("invalid credentials")]
39 InvalidCredentials,
40 #[error("insufficient access key scope: {0:?}")]
41 InsufficientScope(db::user::user_access_keys::AccessKeyScope),
42 #[error("internal MCP track not configured")]
43 InternalNotConfigured,
44 #[error("peer IP is not in the platform whitelist")]
45 IpNotAllowed,
46}
47
48/// can pattern-match the variant. The `#[from] AuthError` on
49impl From<AuthError> for McpError {
50 fn from(e: AuthError) -> Self {
51 match e {
52 AuthError::InternalNotConfigured => McpError::InternalNotConfigured,
53 other => McpError::Auth(other),
54 }
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 #[test]
63 fn ct_eq_basic() {
64 assert!(ct_eq(b"hello", b"hello"));
65 assert!(!ct_eq(b"hello", b"world"));
66 assert!(!ct_eq(b"hello", b"hell"));
67 assert!(!ct_eq(b"hell", b"hello"));
68 assert!(ct_eq(b"", b""));
69 }
70
71 #[test]
72 fn auth_error_maps_to_mcp_error() {
73 let a = AuthError::InternalNotConfigured;
74 let m: McpError = a.into();
75 assert!(matches!(m, McpError::InternalNotConfigured));
76
77 let a = AuthError::InvalidCredentials;
78 let m: McpError = a.into();
79 assert!(matches!(m, McpError::Auth(AuthError::InvalidCredentials)));
80 }
81}
No newline at end of file
Added lib/mcp/ip_filter.rs View lib/mcp/ip_filter.rs
@@ -0,0 +1,158 @@
1use std::net::IpAddr;
2
3use ipnet::IpNet;
4
5use crate::error::McpError;
6
7/// append them via [`config::mcp::McpSettings::internal_allowed_cidrs`].
8pub const BUILTIN_INTERNAL_CIDRS: &[&str] = &[
9 "10.0.0.0/8",
10 "172.16.0.0/12",
11 "192.168.0.0/16",
12 "172.17.0.0/16",
13 "172.18.0.0/16",
14 "172.19.0.0/16",
15 "172.20.0.0/16",
16 "172.21.0.0/16",
17 "172.22.0.0/16",
18 "172.23.0.0/16",
19 "172.24.0.0/16",
20 "172.25.0.0/16",
21 "172.26.0.0/16",
22 "172.27.0.0/16",
23 "172.28.0.0/16",
24 "172.29.0.0/16",
25 "172.30.0.0/16",
26 "172.31.0.0/16",
27 "10.88.0.0/16",
28 "10.96.0.0/12",
29 "10.244.0.0/16",
30 "10.100.0.0/16",
31 "fd00::/8",
32];
33
34#[derive(Debug, Clone)]
35pub struct IpFilter {
36 nets: Vec<IpNet>,
37}
38
39impl IpFilter {
40 pub fn from_settings(extra: &[String]) -> Result<Self, McpError> {
41 let mut nets: Vec<IpNet> = BUILTIN_INTERNAL_CIDRS
42 .iter()
43 .map(|c| {
44 c.parse()
45 .expect("static CIDR in BUILTIN_INTERNAL_CIDRS must be valid")
46 })
47 .collect();
48 for e in extra {
49 nets.push(
50 e.parse()
51 .map_err(|_| McpError::InvalidConfig(format!("bad CIDR {e:?}")))?,
52 );
53 }
54 Ok(Self { nets })
55 }
56
57 pub fn contains(&self, ip: IpAddr) -> bool {
58 self.nets.iter().any(|n| n.contains(&ip))
59 }
60
61 pub fn net_count(&self) -> usize {
62 self.nets.len()
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use std::net::IpAddr;
70
71 fn ip(s: &str) -> IpAddr {
72 s.parse().expect("test ip literal")
73 }
74
75 #[test]
76 fn rfc1918_included() {
77 let f = IpFilter::from_settings(&[]).unwrap();
78 assert!(f.contains(ip("10.0.0.1")));
79 assert!(f.contains(ip("10.255.255.254")));
80 assert!(f.contains(ip("172.16.0.1")));
81 assert!(f.contains(ip("172.31.255.254")));
82 assert!(f.contains(ip("192.168.1.1")));
83 assert!(f.contains(ip("192.168.0.0")));
84 }
85
86 #[test]
87 fn public_ip_excluded() {
88 let f = IpFilter::from_settings(&[]).unwrap();
89 assert!(!f.contains(ip("1.1.1.1")));
90 assert!(!f.contains(ip("8.8.8.8")));
91 assert!(!f.contains(ip("172.32.0.1")));
92 assert!(!f.contains(ip("11.0.0.1")));
93 assert!(!f.contains(ip("172.15.255.255")));
94 }
95
96 #[test]
97 fn docker_bridge_pool_included() {
98 let f = IpFilter::from_settings(&[]).unwrap();
99 for o in 17u8..=31 {
100 assert!(
101 f.contains(IpAddr::from([172, o, 0, 5])),
102 "docker bridge 172.{o}.0.0/16 must be whitelisted"
103 );
104 }
105 }
106
107 #[test]
108 fn podman_bridge_included() {
109 let f = IpFilter::from_settings(&[]).unwrap();
110 assert!(f.contains(ip("10.88.0.1")));
111 assert!(f.contains(ip("10.88.255.254")));
112 assert!(f.contains(ip("10.89.0.1")));
113 assert!(!f.contains(ip("11.0.0.1")));
114 }
115
116 #[test]
117 fn kubernetes_cidrs_included() {
118 let f = IpFilter::from_settings(&[]).unwrap();
119 assert!(f.contains(ip("10.96.0.1")));
120 assert!(f.contains(ip("10.96.204.10")));
121 assert!(f.contains(ip("10.244.0.1")));
122 assert!(f.contains(ip("10.100.0.5")));
123 }
124
125 #[test]
126 fn ipv6_ula_included_other_v6_excluded() {
127 let f = IpFilter::from_settings(&[]).unwrap();
128 assert!(f.contains(ip("fd00::1")));
129 assert!(f.contains(ip("fd12:3456:789a::1")));
130 assert!(!f.contains(ip("2001:db8::1")));
131 assert!(!f.contains(ip("::1")));
132 }
133
134 #[test]
135 fn extra_cidrs_appended() {
136 let extras = vec!["203.0.113.0/24".to_string(), "2001:db8::/32".to_string()];
137 let f = IpFilter::from_settings(&extras).unwrap();
138 assert!(f.contains(ip("203.0.113.5")));
139 assert!(!f.contains(ip("203.0.114.5")));
140 assert!(f.contains(ip("2001:db8::1")));
141 }
142
143 #[test]
144 fn invalid_extra_cidr_rejected() {
145 let r = IpFilter::from_settings(&["not-a-cidr".to_string()]);
146 assert!(matches!(r, Err(McpError::InvalidConfig(_))));
147 }
148
149 #[test]
150 fn builtin_set_parses_cleanly() {
151 for c in BUILTIN_INTERNAL_CIDRS {
152 c.parse::<IpNet>()
153 .unwrap_or_else(|_| panic!("builtin CIDR {c} is invalid"));
154 }
155 let f = IpFilter::from_settings(&[]).unwrap();
156 assert_eq!(f.net_count(), BUILTIN_INTERNAL_CIDRS.len());
157 }
158}
No newline at end of file
Added lib/mcp/lib.rs View lib/mcp/lib.rs
@@ -0,0 +1,15 @@
1//! + `#[tool]` macro server); our crates only contribute auth, the IP
2pub mod access_key;
3pub mod auth;
4pub mod error;
5pub mod ip_filter;
6pub mod router;
7pub mod server;
8pub mod service;
9pub mod service_disabled;
10pub mod tools;
11
12pub use auth::{McpPrincipal, McpTrack};
13pub use error::{AuthError, McpError};
14pub use router::mcp_router;
15pub use service::McpService;
No newline at end of file
Added lib/mcp/router.rs View lib/mcp/router.rs
@@ -0,0 +1,66 @@
1use std::time::Duration;
2
3use axum::extract::DefaultBodyLimit;
4use axum::middleware;
5use axum::Router;
6use rmcp::transport::streamable_http_server::{
7 session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
8};
9use tokio_util::sync::CancellationToken;
10
11use crate::auth;
12use crate::server::McpServer;
13use crate::service::McpService;
14
15pub fn mcp_router(svc: McpService) -> Router {
16 if !svc.settings.enabled {
17 return Router::new()
18 .route(
19 &svc.settings.external_path,
20 axum::routing::any(crate::service_disabled::disabled_handler),
21 )
22 .route(
23 &svc.settings.internal_path,
24 axum::routing::any(crate::service_disabled::disabled_handler),
25 );
26 }
27
28 let http_svc = build_streamable_service(&svc);
29 let external_path = svc.settings.external_path.trim_start_matches('/').to_string();
30 let internal_path = svc.settings.internal_path.trim_start_matches('/').to_string();
31
32 let external = Router::new()
33 .nest_service(&external_path, http_svc.clone())
34 .layer(middleware::from_fn_with_state(
35 svc.clone(),
36 auth::dual_track_auth,
37 ));
38
39 let internal = Router::new()
40 .nest_service(&internal_path, http_svc)
41 .layer(middleware::from_fn_with_state(
42 svc.clone(),
43 auth::internal_only_auth,
44 ));
45
46 Router::new()
47 .merge(external)
48 .merge(internal)
49 .layer(DefaultBodyLimit::max(svc.settings.max_request_bytes))
50}
51
52fn build_streamable_service(
53 svc: &McpService,
54) -> StreamableHttpService<McpServer, LocalSessionManager> {
55 let cancel = CancellationToken::new();
56 let svc_clone = svc.clone();
57 let factory = move || Ok(McpServer::new(&svc_clone));
58 StreamableHttpService::new(
59 factory,
60 LocalSessionManager::default().into(),
61 StreamableHttpServerConfig::default()
62 .with_json_response(true)
63 .with_sse_keep_alive(Some(Duration::from_secs(15)))
64 .with_cancellation_token(cancel),
65 )
66}
No newline at end of file
Added lib/mcp/server.rs View lib/mcp/server.rs
@@ -0,0 +1,341 @@
1//! `repo/issues/project/space`. Authorization semantics inherit the same
2//! * per-call scope gating (`McpPrincipal::tool_allowed(wants_write)`) so a
3//! Server identity is filled in by the `#[tool_router]` macro from the
4use std::sync::Arc;
5
6use issues::service::IssueService;
7use project::service::ProjectService;
8use repo::service::RepoService;
9use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
10use rmcp::model::*;
11use rmcp::service::RequestContext;
12use rmcp::{tool, tool_handler, tool_router, ErrorData as McpErr, RoleServer, ServerHandler};
13use space::features::SpaceService;
14use uuid::Uuid;
15
16use config::mcp::McpSettings;
17
18use crate::auth::McpPrincipal;
19use crate::service::McpService;
20use crate::tools::{
21 CreateIssueArgs, GetIssueArgs, GetProjectArgs, GetRepoArgs, GetSpaceArgs, ListIssueArgs,
22 ListProjectArgs, ListRepoArgs, ListSpaceArgs,
23};
24
25#[derive(Clone)]
26pub struct McpServer {
27 repo: Arc<RepoService>,
28 issues: Arc<IssueService>,
29 project: Arc<ProjectService>,
30 space: Arc<SpaceService>,
31 settings: Arc<McpSettings>,
32 // `tool_router` is read by the `#[tool_router]` macro to register the
33 // Rust warns `dead_code`. Keep it but mark it intentionally read by the
34 // macro so the warning is silenced.
35 #[allow(dead_code)]
36 tool_router: ToolRouter<McpServer>,
37}
38
39impl McpServer {
40 pub fn new(svc: &McpService) -> Self {
41 Self {
42 repo: svc.repo.clone(),
43 issues: svc.issues.clone(),
44 project: svc.project.clone(),
45 space: svc.space.clone(),
46 settings: svc.settings.clone(),
47 tool_router: Self::tool_router(),
48 }
49 }
50}
51
52#[tool_handler]
53impl ServerHandler for McpServer {
54 // ...) are generated from the `#[tool]` methods below.
55}
56
57#[tool_router]
58impl McpServer {
59
60 fn principal(ctx: &RequestContext<RoleServer>) -> Result<McpPrincipal, McpErr> {
61 let parts = ctx
62 .extensions
63 .get::<http::request::Parts>()
64 .ok_or_else(|| McpErr::internal_error("mcp auth context missing", None))?;
65 parts
66 .extensions
67 .get::<McpPrincipal>()
68 .cloned()
69 .ok_or_else(|| McpErr::internal_error("mcp auth context missing", None))
70 }
71
72 fn actor(
73 principal: &McpPrincipal,
74 settings: &McpSettings,
75 ) -> Result<Uuid, McpErr> {
76 Self::actor_via(principal, settings).map_err(|e| {
77 McpErr::invalid_request(format!("actor resolution failed: {e}"), None)
78 })
79 }
80
81 fn actor_via(
82 principal: &McpPrincipal,
83 settings: &McpSettings,
84 ) -> Result<Uuid, String> {
85 match principal {
86 McpPrincipal::External { user_id, .. } => Ok(*user_id),
87 McpPrincipal::Internal => settings
88 .system_user_id
89 .as_deref()
90 .map(Uuid::parse_str)
91 .transpose()
92 .map_err(|e| e.to_string())?
93 .ok_or_else(|| "mcp.system_user_id not configured".to_string()),
94 }
95 }
96
97 fn scope_gate(principal: &McpPrincipal, wants_write: bool) -> Result<(), McpErr> {
98 if principal.tool_allowed(wants_write) {
99 Ok(())
100 } else {
101 Err(McpErr::invalid_request(
102 "access key scope does not permit this tool",
103 None,
104 ))
105 }
106 }
107
108 fn to_text<T: serde::Serialize>(value: &T) -> Result<String, McpErr> {
109 serde_json::to_string(value).map_err(|e| McpErr::internal_error(
110 format!("serialization: {e}"),
111 None,
112 ))
113 }
114
115 fn tool_err(tool: &str, e: impl std::fmt::Display) -> McpErr {
116 McpErr::internal_error(format!("{tool}: {e}"), None)
117 }
118
119 fn bad_arg(e: String) -> McpErr {
120 McpErr::invalid_params(e, None)
121 }
122
123
124 #[tool(
125 name = "repo.list_repos",
126 description = "List repositories visible to the caller. Optional filters: channel_id, q (name substring), is_private, is_archived, plus pagination (limit, offset)."
127 )]
128 async fn list_repos(
129 &self,
130 Parameters(args): Parameters<ListRepoArgs>,
131 ctx: RequestContext<RoleServer>,
132 ) -> Result<CallToolResult, McpErr> {
133 let principal = Self::principal(&ctx)?;
134 Self::scope_gate(&principal, false)?;
135 let actor = Self::actor(&principal, &self.settings)?;
136 let query = args
137 .into_query()
138 .map_err(Self::bad_arg)?;
139 let page = repo::features::lifecycle::usecase::list_repos(&self.repo, actor, query)
140 .await
141 .map_err(|e| Self::tool_err("repo.list_repos", e))?;
142 Ok(CallToolResult::success(vec![ContentBlock::text(
143 Self::to_text(&page)?,
144 )]))
145 }
146
147 #[tool(
148 name = "repo.get_repo",
149 description = "Fetch a single repository by channel_id and repo_id."
150 )]
151 async fn get_repo(
152 &self,
153 Parameters(args): Parameters<GetRepoArgs>,
154 ctx: RequestContext<RoleServer>,
155 ) -> Result<CallToolResult, McpErr> {
156 let principal = Self::principal(&ctx)?;
157 Self::scope_gate(&principal, false)?;
158 let actor = Self::actor(&principal, &self.settings)?;
159 let (channel_id, repo_id) = args.parsed().map_err(Self::bad_arg)?;
160 let view = repo::features::lifecycle::usecase::get_repo(
161 &self.repo,
162 actor,
163 channel_id,
164 repo_id,
165 )
166 .await
167 .map_err(|e| Self::tool_err("repo.get_repo", e))?;
168 Ok(CallToolResult::success(vec![ContentBlock::text(
169 Self::to_text(&view)?,
170 )]))
171 }
172
173
174 #[tool(
175 name = "issue.list_issues",
176 description = "List issues in a channel. Filters: state (open/closed), author_id, q, pagination."
177 )]
178 async fn list_issues(
179 &self,
180 Parameters(args): Parameters<ListIssueArgs>,
181 ctx: RequestContext<RoleServer>,
182 ) -> Result<CallToolResult, McpErr> {
183 let principal = Self::principal(&ctx)?;
184 Self::scope_gate(&principal, false)?;
185 let actor = Self::actor(&principal, &self.settings)?;
186 let (channel_id, query) = args.into_parts().map_err(Self::bad_arg)?;
187 let page = issues::features::issue::usecase::list_issues(
188 &self.issues,
189 actor,
190 channel_id,
191 query,
192 )
193 .await
194 .map_err(|e| Self::tool_err("issue.list_issues", e))?;
195 Ok(CallToolResult::success(vec![ContentBlock::text(
196 Self::to_text(&page)?,
197 )]))
198 }
199
200 #[tool(
201 name = "issue.get_issue",
202 description = "Fetch a single issue by channel_id and issue_id."
203 )]
204 async fn get_issue(
205 &self,
206 Parameters(args): Parameters<GetIssueArgs>,
207 ctx: RequestContext<RoleServer>,
208 ) -> Result<CallToolResult, McpErr> {
209 let principal = Self::principal(&ctx)?;
210 Self::scope_gate(&principal, false)?;
211 let actor = Self::actor(&principal, &self.settings)?;
212 let (channel_id, issue_id) = args.parsed().map_err(Self::bad_arg)?;
213 let view = issues::features::issue::usecase::get_issue(
214 &self.issues,
215 actor,
216 channel_id,
217 issue_id,
218 )
219 .await
220 .map_err(|e| Self::tool_err("issue.get_issue", e))?;
221 Ok(CallToolResult::success(vec![ContentBlock::text(
222 Self::to_text(&view)?,
223 )]))
224 }
225
226 #[tool(
227 name = "issue.create_issue",
228 description = "Create an issue in a channel. Write tool — requires an Access Key with a write-capable scope (Write/Repo/Admin/All) or the internal track."
229 )]
230 async fn create_issue(
231 &self,
232 Parameters(args): Parameters<CreateIssueArgs>,
233 ctx: RequestContext<RoleServer>,
234 ) -> Result<CallToolResult, McpErr> {
235 let principal = Self::principal(&ctx)?;
236 Self::scope_gate(&principal, true)?;
237 let actor = Self::actor(&principal, &self.settings)?;
238 let (channel_id, input) = args.into_parts().map_err(Self::bad_arg)?;
239 let view =
240 issues::features::issue::usecase::create_issue(&self.issues, actor, channel_id, input)
241 .await
242 .map_err(|e| Self::tool_err("issue.create_issue", e))?;
243 Ok(CallToolResult::success(vec![ContentBlock::text(
244 Self::to_text(&view)?,
245 )]))
246 }
247
248
249 #[tool(
250 name = "project.list_projects",
251 description = "List projects in a space. Filters: state, lead_id, q, page/per_page."
252 )]
253 async fn list_projects(
254 &self,
255 Parameters(args): Parameters<ListProjectArgs>,
256 ctx: RequestContext<RoleServer>,
257 ) -> Result<CallToolResult, McpErr> {
258 let principal = Self::principal(&ctx)?;
259 Self::scope_gate(&principal, false)?;
260 let actor = Self::actor(&principal, &self.settings)?;
261 let query = args.into_query().map_err(Self::bad_arg)?;
262 let page = self
263 .project
264 .list_projects(actor, query)
265 .await
266 .map_err(|e| Self::tool_err("project.list_projects", e))?;
267 Ok(CallToolResult::success(vec![ContentBlock::text(
268 Self::to_text(&page)?,
269 )]))
270 }
271
272 #[tool(
273 name = "project.get_project",
274 description = "Fetch a single project by space_id and project_id."
275 )]
276 async fn get_project(
277 &self,
278 Parameters(args): Parameters<GetProjectArgs>,
279 ctx: RequestContext<RoleServer>,
280 ) -> Result<CallToolResult, McpErr> {
281 let principal = Self::principal(&ctx)?;
282 Self::scope_gate(&principal, false)?;
283 let actor = Self::actor(&principal, &self.settings)?;
284 let (space_id, project_id) = args.parsed().map_err(Self::bad_arg)?;
285 let view = self
286 .project
287 .get_project(actor, space_id, project_id)
288 .await
289 .map_err(|e| Self::tool_err("project.get_project", e))?;
290 Ok(CallToolResult::success(vec![ContentBlock::text(
291 Self::to_text(&view)?,
292 )]))
293 }
294
295
296 #[tool(
297 name = "space.list_spaces",
298 description = "List spaces visible to the caller. Pagination: limit, offset."
299 )]
300 async fn list_spaces(
301 &self,
302 Parameters(args): Parameters<ListSpaceArgs>,
303 ctx: RequestContext<RoleServer>,
304 ) -> Result<CallToolResult, McpErr> {
305 let principal = Self::principal(&ctx)?;
306 Self::scope_gate(&principal, false)?;
307 let actor = Self::actor(&principal, &self.settings)?;
308 let (limit, offset) = crate::tools::space_page_args(args);
309 let page = self
310 .space
311 .list_spaces(actor, limit, offset)
312 .await
313 .map_err(|e| Self::tool_err("space.list_spaces", e))?;
314 Ok(CallToolResult::success(vec![ContentBlock::text(
315 Self::to_text(&page)?,
316 )]))
317 }
318
319 #[tool(
320 name = "space.get_space",
321 description = "Fetch a single space by space_id."
322 )]
323 async fn get_space(
324 &self,
325 Parameters(args): Parameters<GetSpaceArgs>,
326 ctx: RequestContext<RoleServer>,
327 ) -> Result<CallToolResult, McpErr> {
328 let principal = Self::principal(&ctx)?;
329 Self::scope_gate(&principal, false)?;
330 let actor = Self::actor(&principal, &self.settings)?;
331 let space_id = args.parsed().map_err(Self::bad_arg)?;
332 let view = self
333 .space
334 .get_space(Some(actor), space_id)
335 .await
336 .map_err(|e| Self::tool_err("space.get_space", e))?;
337 Ok(CallToolResult::success(vec![ContentBlock::text(
338 Self::to_text(&view)?,
339 )]))
340 }
341}
No newline at end of file
Added lib/mcp/service.rs View lib/mcp/service.rs
@@ -0,0 +1,61 @@
1use std::sync::Arc;
2
3use issues::service::IssueService;
4use project::service::ProjectService;
5use repo::service::RepoService;
6use space::features::SpaceService;
7use uuid::Uuid;
8
9use config::mcp::McpSettings;
10
11use crate::error::McpError;
12use crate::ip_filter::IpFilter;
13
14#[derive(Clone)]
15pub struct McpService {
16 pub settings: Arc<McpSettings>,
17 pub db: db::conn::Db,
18 pub ip_filter: Arc<IpFilter>,
19 pub repo: Arc<RepoService>,
20 pub issues: Arc<IssueService>,
21 pub project: Arc<ProjectService>,
22 pub space: Arc<SpaceService>,
23}
24
25impl McpService {
26 pub fn new(
27 settings: McpSettings,
28 db: db::conn::Db,
29 repo: RepoService,
30 issues: IssueService,
31 project: ProjectService,
32 space: SpaceService,
33 ) -> Result<Self, McpError> {
34 let ip_filter = IpFilter::from_settings(&settings.internal_allowed_cidrs)?;
35 if let Some(uid) = &settings.system_user_id {
36 Uuid::parse_str(uid).map_err(|_| {
37 McpError::InvalidConfig("mcp.system_user_id is not a valid UUID".into())
38 })?;
39 }
40 Ok(Self {
41 settings: Arc::new(settings),
42 db,
43 ip_filter: Arc::new(ip_filter),
44 repo: Arc::new(repo),
45 issues: Arc::new(issues),
46 project: Arc::new(project),
47 space: Arc::new(space),
48 })
49 }
50
51 pub fn system_user_uuid(&self) -> Result<Uuid, McpError> {
52 let s = self
53 .settings
54 .system_user_id
55 .as_deref()
56 .ok_or(McpError::InternalNotConfigured)?;
57 Uuid::parse_str(s).map_err(|_| {
58 McpError::InvalidConfig("mcp.system_user_id is not a valid UUID".into())
59 })
60 }
61}
No newline at end of file
Added lib/mcp/service_disabled.rs View lib/mcp/service_disabled.rs
@@ -0,0 +1,11 @@
1use axum::extract::Request;
2use axum::http::StatusCode;
3use axum::response::{IntoResponse, Response};
4
5pub async fn disabled_handler(_req: Request) -> Response {
6 (
7 StatusCode::SERVICE_UNAVAILABLE,
8 axum::Json(serde_json::json!({"error": "mcp disabled by configuration"})),
9 )
10 .into_response()
11}
No newline at end of file
Added lib/mcp/tools.rs View lib/mcp/tools.rs
@@ -0,0 +1,233 @@
1use rmcp::schemars;
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
7pub struct ListRepoArgs {
8 #[schemars(description = "optional channel UUID filter")]
9 pub channel_id: Option<String>,
10 #[schemars(description = "optional free-text name match")]
11 pub q: Option<String>,
12 #[schemars(description = "filter by privacy")]
13 pub is_private: Option<bool>,
14 #[schemars(description = "filter by archived flag")]
15 pub is_archived: Option<bool>,
16 #[schemars(description = "page size (default 20)")]
17 pub limit: Option<u64>,
18 #[schemars(description = "page offset (default 0)")]
19 pub offset: Option<u64>,
20}
21
22pub fn parse_uuid(s: &str, field: &str) -> Result<Uuid, String> {
23 Uuid::parse_str(s).map_err(|e| format!("invalid uuid for {field}: {e}"))
24}
25
26impl ListRepoArgs {
27 pub fn into_query(self) -> Result<export::repo::lifecycle::ListRepoQuery, String> {
28 let channel_id = match self.channel_id.as_deref() {
29 Some(s) if !s.is_empty() => Some(parse_uuid(s, "channel_id")?),
30 _ => None,
31 };
32 Ok(export::repo::lifecycle::ListRepoQuery {
33 channel_id,
34 owner_user_id: None,
35 is_private: self.is_private,
36 is_archived: self.is_archived,
37 q: self.q,
38 limit: self.limit,
39 offset: self.offset,
40 })
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
45pub struct GetRepoArgs {
46 pub channel_id: String,
47 pub repo_id: String,
48}
49
50impl GetRepoArgs {
51 pub fn parsed(&self) -> Result<(Uuid, Uuid), String> {
52 Ok((
53 parse_uuid(&self.channel_id, "channel_id")?,
54 parse_uuid(&self.repo_id, "repo_id")?,
55 ))
56 }
57}
58
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
61pub struct ListIssueArgs {
62 pub channel_id: String,
63 #[schemars(description = "filter by state: \"open\" or \"closed\"")]
64 pub state: Option<String>,
65 pub author_id: Option<String>,
66 pub q: Option<String>,
67 pub limit: Option<u64>,
68 pub offset: Option<u64>,
69}
70
71impl ListIssueArgs {
72 pub fn into_parts(
73 self,
74 ) -> Result<(Uuid, export::issues::issue::ListIssueQuery), String> {
75 let channel_id = parse_uuid(&self.channel_id, "channel_id")?;
76 let author_id = match self.author_id.as_deref() {
77 Some(s) if !s.is_empty() => Some(parse_uuid(s, "author_id")?),
78 _ => None,
79 };
80 let state = match self.state.as_deref() {
81 Some(s) if !s.is_empty() => Some(parse_issue_state(s)?),
82 _ => None,
83 };
84 let q = export::issues::issue::ListIssueQuery {
85 state,
86 author_id,
87 milestone_id: None,
88 q: self.q,
89 limit: self.limit,
90 offset: self.offset,
91 };
92 Ok((channel_id, q))
93 }
94}
95
96fn parse_issue_state(s: &str) -> Result<export::issues::issue::IssueState, String> {
97 use export::issues::issue::IssueState;
98 match s.trim().to_ascii_lowercase().as_str() {
99 "open" => Ok(IssueState::Open),
100 "closed" => Ok(IssueState::Closed),
101 other => Err(format!(
102 "invalid state {other:?}: expected \"open\" or \"closed\""
103 )),
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
108pub struct GetIssueArgs {
109 pub channel_id: String,
110 pub issue_id: String,
111}
112
113impl GetIssueArgs {
114 pub fn parsed(&self) -> Result<(Uuid, Uuid), String> {
115 Ok((
116 parse_uuid(&self.channel_id, "channel_id")?,
117 parse_uuid(&self.issue_id, "issue_id")?,
118 ))
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
123pub struct CreateIssueArgs {
124 pub channel_id: String,
125 pub title: String,
126 pub body: String,
127 pub description: Option<String>,
128 pub milestone_id: Option<String>,
129}
130
131impl CreateIssueArgs {
132 pub fn into_parts(
133 self,
134 ) -> Result<(Uuid, export::issues::issue::CreateIssueOption), String> {
135 let channel_id = parse_uuid(&self.channel_id, "channel_id")?;
136 let milestone_id = match self.milestone_id.as_deref() {
137 Some(s) if !s.is_empty() => Some(parse_uuid(s, "milestone_id")?),
138 _ => None,
139 };
140 let input = export::issues::issue::CreateIssueOption {
141 title: self.title,
142 description: self.description,
143 body: self.body,
144 milestone_id,
145 };
146 Ok((channel_id, input))
147 }
148}
149
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
152pub struct ListProjectArgs {
153 pub space_id: String,
154 #[schemars(description = "filter by project state (e.g. \"Backlog\", \"Planned\", \"InProgress\", \"Paused\", \"Completed\", \"Cancelled\")")]
155 pub state: Option<String>,
156 pub lead_id: Option<String>,
157 pub q: Option<String>,
158 pub page: Option<u64>,
159 pub per_page: Option<u64>,
160}
161
162impl ListProjectArgs {
163 pub fn into_query(self) -> Result<export::project::lifecycle::ListProjectQuery, String> {
164 let space_id = parse_uuid(&self.space_id, "space_id")?;
165 let lead_id = match self.lead_id.as_deref() {
166 Some(s) if !s.is_empty() => Some(parse_uuid(s, "lead_id")?),
167 _ => None,
168 };
169 let state = match self.state.as_deref() {
170 Some(s) if !s.is_empty() => Some(parse_project_state(s)?),
171 _ => None,
172 };
173 Ok(export::project::lifecycle::ListProjectQuery {
174 space_id,
175 state,
176 lead_id,
177 starred_only: None,
178 q: self.q,
179 page: self.page.unwrap_or(1),
180 per_page: self.per_page.unwrap_or(20),
181 })
182 }
183}
184
185fn parse_project_state(s: &str) -> Result<export::project::lifecycle::ProjectState, String> {
186 use export::project::lifecycle::ProjectState;
187 match s.trim() {
188 "Backlog" => Ok(ProjectState::Backlog),
189 "Planned" => Ok(ProjectState::Planned),
190 "InProgress" => Ok(ProjectState::InProgress),
191 "Paused" => Ok(ProjectState::Paused),
192 "Completed" => Ok(ProjectState::Completed),
193 "Cancelled" => Ok(ProjectState::Cancelled),
194 other => Err(format!("invalid project state {other:?}")),
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
199pub struct GetProjectArgs {
200 pub space_id: String,
201 pub project_id: String,
202}
203
204impl GetProjectArgs {
205 pub fn parsed(&self) -> Result<(Uuid, Uuid), String> {
206 Ok((
207 parse_uuid(&self.space_id, "space_id")?,
208 parse_uuid(&self.project_id, "project_id")?,
209 ))
210 }
211}
212
213
214#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
215pub struct ListSpaceArgs {
216 pub limit: Option<u64>,
217 pub offset: Option<u64>,
218}
219
220pub fn space_page_args(args: ListSpaceArgs) -> (u64, u64) {
221 (args.limit.unwrap_or(20), args.offset.unwrap_or(0))
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
225pub struct GetSpaceArgs {
226 pub space_id: String,
227}
228
229impl GetSpaceArgs {
230 pub fn parsed(&self) -> Result<Uuid, String> {
231 parse_uuid(&self.space_id, "space_id")
232 }
233}
No newline at end of file