1#![allow(clippy::unused_unit, deprecated)]
12
13use dioxus::prelude::*;
14
15#[cfg(feature = "server")]
16use crate::models::mcp_token::McpToken;
17use crate::models::mcp_token::{CreateTokenResponse, McpTokenSummary, TokenScope};
18
19#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
24#[serde(rename_all = "lowercase")]
25pub enum TokenLifetime {
26 Days1,
28 Days7,
30 Days30,
32 Days90,
34 Never,
36}
37
38impl TokenLifetime {
39 #[cfg(feature = "server")]
41 fn expires_at(self) -> Option<chrono::DateTime<chrono::Utc>> {
42 let now = chrono::Utc::now();
43 match self {
44 TokenLifetime::Days1 => Some(now + chrono::Duration::days(1)),
45 TokenLifetime::Days7 => Some(now + chrono::Duration::days(7)),
46 TokenLifetime::Days30 => Some(now + chrono::Duration::days(30)),
47 TokenLifetime::Days90 => Some(now + chrono::Duration::days(90)),
48 TokenLifetime::Never => None,
49 }
50 }
51}
52
53#[server(CreateMcpToken, "/api")]
58pub async fn create_mcp_token(
59 name: String,
60 scope: TokenScope,
61 lifetime: TokenLifetime,
62) -> Result<CreateTokenResponse, ServerFnError> {
63 #[cfg(feature = "server")]
64 {
65 use crate::api::auth::get_current_admin_user;
66 use crate::api::error::AppError;
67 use crate::db::pool::get_conn;
68 use crate::mcp::auth::{hash_token, TOKEN_PREFIX};
69 use crate::mcp::crypto::encrypt_token;
70
71 let admin = get_current_admin_user().await?;
72
73 let name = name.trim().to_string();
75 if name.is_empty() {
76 return Err(AppError::BadRequest("令牌名称不能为空".to_string()).into());
77 }
78 if name.chars().count() > 64 {
79 return Err(AppError::BadRequest("令牌名称过长(上限 64 字符)".to_string()).into());
80 }
81
82 if crate::mcp::crypto::mcp_enc_key().is_none() {
84 return Err(AppError::Internal("MCP_TOKEN_ENC_KEY 未设置").into());
85 }
86
87 let mut bytes = [0u8; 32];
89 rand::TryRng::try_fill_bytes(&mut rand::rngs::SysRng, &mut bytes)
90 .map_err(|_| AppError::Internal("令牌随机数生成失败"))?;
91 let plaintext = format!("{TOKEN_PREFIX}{}", hex::encode(bytes));
92 let hash = hash_token(&plaintext);
93 let enc = encrypt_token(&plaintext).ok_or(AppError::Internal("MCP 令牌加密失败"))?;
94 let id = uuid::Uuid::new_v4();
95 let expires_at = lifetime.expires_at();
96 let scope_str = scope.as_str();
97
98 let client = get_conn().await.map_err(AppError::db_conn)?;
99
100 let row = client
101 .query_one(
102 "INSERT INTO mcp_tokens \
103 (id, user_id, name, scope, token_enc, token_hash, expires_at) \
104 VALUES ($1::uuid, $2, $3, $4, $5, $6, $7) \
105 RETURNING id::text, user_id, name, scope, created_at, expires_at, \
106 last_used_at, revoked_at",
107 &[&id, &admin.id, &name, &scope_str, &enc, &hash, &expires_at],
108 )
109 .await
110 .map_err(AppError::query)?;
111
112 let token = row_to_mcp_token_meta(&row);
113 Ok(CreateTokenResponse {
114 summary: token.into(),
115 plaintext,
116 })
117 }
118 #[cfg(not(feature = "server"))]
119 unreachable!()
120}
121
122#[server(ListMcpTokens, "/api")]
126pub async fn list_mcp_tokens() -> Result<Vec<McpTokenSummary>, ServerFnError> {
127 #[cfg(feature = "server")]
128 {
129 use crate::api::auth::get_current_admin_user;
130 use crate::api::error::AppError;
131 use crate::db::pool::get_conn;
132
133 let admin = get_current_admin_user().await?;
134 let client = get_conn().await.map_err(AppError::db_conn)?;
135
136 let rows = client
137 .query(
138 "SELECT id::text, user_id, name, scope, created_at, expires_at, \
139 last_used_at, revoked_at \
140 FROM mcp_tokens \
141 WHERE user_id = $1 \
142 ORDER BY created_at DESC",
143 &[&admin.id],
144 )
145 .await
146 .map_err(AppError::query)?;
147
148 Ok(rows
149 .iter()
150 .map(row_to_mcp_token_meta)
151 .map(McpTokenSummary::from)
152 .collect())
153 }
154 #[cfg(not(feature = "server"))]
155 unreachable!()
156}
157
158#[server(RevealMcpToken, "/api")]
163pub async fn reveal_mcp_token(id: String) -> Result<Option<String>, ServerFnError> {
164 #[cfg(feature = "server")]
165 {
166 use crate::api::auth::get_current_admin_user;
167 use crate::api::error::AppError;
168 use crate::db::pool::get_conn;
169 use crate::mcp::crypto::decrypt_token;
170
171 let admin = get_current_admin_user().await?;
172 let client = get_conn().await.map_err(AppError::db_conn)?;
173
174 let id = match uuid::Uuid::parse_str(&id) {
176 Ok(u) => u,
177 Err(_) => return Ok(None),
178 };
179
180 let row = client
182 .query_opt(
183 "SELECT token_enc FROM mcp_tokens WHERE id = $1::uuid AND user_id = $2",
184 &[&id, &admin.id],
185 )
186 .await
187 .map_err(AppError::query)?;
188
189 Ok(row
192 .map(|r| r.get::<_, String>("token_enc"))
193 .and_then(|enc| decrypt_token(&enc)))
194 }
195 #[cfg(not(feature = "server"))]
196 unreachable!()
197}
198
199#[server(RevokeMcpToken, "/api")]
203pub async fn revoke_mcp_token(id: String) -> Result<(), ServerFnError> {
204 #[cfg(feature = "server")]
205 {
206 use crate::api::auth::get_current_admin_user;
207 use crate::api::error::AppError;
208 use crate::db::pool::get_conn;
209
210 let admin = get_current_admin_user().await?;
211 let client = get_conn().await.map_err(AppError::db_conn)?;
212
213 let id = match uuid::Uuid::parse_str(&id) {
215 Ok(u) => u,
216 Err(_) => return Ok(()),
217 };
218
219 client
220 .execute(
221 "UPDATE mcp_tokens SET revoked_at = NOW() \
222 WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL",
223 &[&id, &admin.id],
224 )
225 .await
226 .map_err(AppError::query)?;
227
228 Ok(())
229 }
230 #[cfg(not(feature = "server"))]
231 unreachable!()
232}
233
234#[cfg(feature = "server")]
239fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
240 let scope_str: String = row.get("scope");
241 let scope = TokenScope::from_db(&scope_str).unwrap_or_else(|| {
242 tracing::warn!(scope = %scope_str, "mcp_tokens.scope 非法值,兜底为 read");
243 TokenScope::Read
244 });
245 McpToken {
246 id: row.get("id"),
247 user_id: row.get("user_id"),
248 name: row.get("name"),
249 scope,
250 token_enc: String::new(),
251 token_hash: String::new(),
252 created_at: row.get("created_at"),
253 expires_at: row.get("expires_at"),
254 last_used_at: row.get("last_used_at"),
255 revoked_at: row.get("revoked_at"),
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
265pub struct McpConfigSnippet {
266 pub title: String,
268 pub content: String,
270 pub content_html: String,
272}
273
274#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
280pub struct McpClientConfigs {
281 pub snippets: Vec<McpConfigSnippet>,
283}
284
285#[server(GetMcpClientConfigs, "/api")]
290pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
291 #[cfg(feature = "server")]
292 {
293 use crate::api::auth::get_current_admin_user;
294 use crate::highlight::server::highlight_code;
295
296 let _admin = get_current_admin_user().await?;
297 let c = crate::mcp::config::generate_client_configs(
298 &crate::mcp::config::base_url_from_env(),
299 &token,
300 );
301 let entries: [(&str, String, &str); 7] = [
303 (
304 "Oh-My-Pi(项目根 .mcp.json / ~/.omp/agent/mcp.json 或 ~/.mcp.json)",
305 c.omp_json,
306 "json",
307 ),
308 (
309 "OpenCode(~/.config/opencode/opencode.json 或项目根 opencode.json)",
310 c.opencode_json,
311 "json",
312 ),
313 (
314 "Claude Code(.mcp.json / ~/.claude.json)",
315 c.claude_code_json,
316 "json",
317 ),
318 ("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
319 ("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
320 ("通用(单 server entry)", c.generic_json, "json"),
321 ("Claude Code CLI", c.claude_cli, "bash"),
322 ];
323 let snippets = entries
324 .into_iter()
325 .map(|(title, content, lang)| McpConfigSnippet {
326 title: title.to_string(),
327 content_html: highlight_code(&content, Some(lang)),
328 content,
329 })
330 .collect();
331 Ok(McpClientConfigs { snippets })
332 }
333 #[cfg(not(feature = "server"))]
334 unreachable!()
335}
336
337#[cfg(all(test, feature = "server"))]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn lifetime_expires_at_days() {
343 let now = chrono::Utc::now();
344 let d1 = TokenLifetime::Days1.expires_at().unwrap();
345 let d7 = TokenLifetime::Days7.expires_at().unwrap();
346 assert!(d1 > now);
347 assert!(d7 > d1);
348 let delta = (d7 - d1).num_seconds() as f64 / 86400.0;
350 assert!((5.9..6.1).contains(&delta));
351 }
352
353 #[test]
354 fn lifetime_never_is_none() {
355 assert!(TokenLifetime::Never.expires_at().is_none());
356 }
357
358 #[test]
359 fn lifetime_serde_roundtrip() {
360 let json = serde_json::to_string(&TokenLifetime::Days30).unwrap();
361 assert_eq!(json, "\"days30\"");
362 let back: TokenLifetime = serde_json::from_str(&json).unwrap();
363 assert_eq!(back, TokenLifetime::Days30);
364 }
365}