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