1#![allow(clippy::unused_unit, deprecated)]
12
13use crate::api::error::ApiError;
14use leptos::prelude::*;
15
16#[cfg(feature = "server")]
17use crate::models::mcp_token::McpToken;
18use crate::models::mcp_token::{CreateTokenResponse, McpTokenSummary, NoteGrant, TokenScope};
19
20#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
25#[serde(rename_all = "lowercase")]
26pub enum TokenLifetime {
27 Days1,
29 Days7,
31 Days30,
33 Days90,
35 Never,
37}
38
39impl TokenLifetime {
40 #[cfg(feature = "server")]
42 fn expires_at(self) -> Option<chrono::DateTime<chrono::Utc>> {
43 let now = chrono::Utc::now();
44 match self {
45 TokenLifetime::Days1 => Some(now + chrono::Duration::days(1)),
46 TokenLifetime::Days7 => Some(now + chrono::Duration::days(7)),
47 TokenLifetime::Days30 => Some(now + chrono::Duration::days(30)),
48 TokenLifetime::Days90 => Some(now + chrono::Duration::days(90)),
49 TokenLifetime::Never => None,
50 }
51 }
52}
53
54#[server(CreateMcpToken, endpoint = "create_mcp_token", input = leptos::server_fn::codec::Json)]
59pub async fn create_mcp_token(
60 name: String,
61 scope: TokenScope,
62 lifetime: TokenLifetime,
63 notes: Option<crate::models::mcp_token::NoteGrant>,
64) -> Result<CreateTokenResponse, ApiError> {
65 #[cfg(feature = "server")]
66 {
67 use crate::api::auth::get_current_admin_user;
68 use crate::api::error::AppError;
69 use crate::db::pool::get_conn;
70 use crate::mcp::auth::{hash_token, TOKEN_PREFIX};
71 use crate::mcp::crypto::encrypt_token;
72
73 let admin = get_current_admin_user().await?;
74
75 let name = name.trim().to_string();
77 crate::models::mcp_token::validate_token_name(&name)
78 .map_err(|message| AppError::BadRequest(message.to_string()))?;
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::TryRng::try_fill_bytes(&mut rand::rngs::SysRng, &mut bytes)
88 .map_err(|_| AppError::Internal("令牌随机数生成失败"))?;
89 let plaintext = format!("{TOKEN_PREFIX}{}", hex::encode(bytes));
90 let hash = hash_token(&plaintext);
91 let enc = encrypt_token(&plaintext).ok_or(AppError::Internal("MCP 令牌加密失败"))?;
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 notes = validate_note_grant(&client, admin.id, notes.unwrap_or_default()).await?;
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, notes_read, notes_write, notebook_ids) \
104 VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
105 RETURNING id::text, user_id, name, scope, created_at, expires_at, \
106 last_used_at, revoked_at, notes_read, notes_write, notebook_ids",
107 &[&id, &admin.id, &name, &scope_str, &enc, &hash, &expires_at, ¬es.read, ¬es.write, ¬es.notebook_ids],
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(UpdateMcpTokenPermissions, endpoint = "update_mcp_token_permissions", input = leptos::server_fn::codec::Json)]
124pub async fn update_mcp_token_permissions(
125 id: String,
126 scope: TokenScope,
127 notes: NoteGrant,
128) -> Result<(), ApiError> {
129 let admin = crate::api::auth::get_current_admin_user().await?;
130 let client = crate::db::pool::get_conn()
131 .await
132 .map_err(crate::api::error::AppError::db_conn)?;
133 save_token_permissions(&client, admin.id, &id, scope, notes).await?;
134 Ok(())
135}
136
137#[cfg(feature = "server")]
138async fn validate_note_grant(
139 client: &tokio_postgres::Client,
140 owner_id: i32,
141 mut notes: NoteGrant,
142) -> Result<NoteGrant, crate::api::error::AppError> {
143 use crate::api::error::AppError;
144 notes.read |= notes.write;
145 if !notes.read {
146 notes.notebook_ids = None;
147 }
148 if let Some(ids) = notes.notebook_ids.as_mut() {
149 ids.sort_unstable();
150 ids.dedup();
151 let owned: i64 = client
152 .query_one(
153 "SELECT COUNT(*) FROM notebooks WHERE owner_id=$1 AND id=ANY($2)",
154 &[&owner_id, &ids.as_slice()],
155 )
156 .await
157 .map_err(AppError::query)?
158 .get(0);
159 if ids.is_empty() || owned != ids.len() as i64 {
160 return Err(AppError::BadRequest("请选择自己拥有的笔记本".into()));
161 }
162 }
163 Ok(notes)
164}
165
166#[cfg(feature = "server")]
167async fn save_token_permissions(
168 client: &tokio_postgres::Client,
169 owner_id: i32,
170 id: &str,
171 scope: TokenScope,
172 notes: NoteGrant,
173) -> Result<(), crate::api::error::AppError> {
174 use crate::api::error::AppError;
175 let invalid_token = || AppError::BadRequest("令牌不存在或已失效,请刷新列表".into());
176 let id = uuid::Uuid::parse_str(id).map_err(|_| invalid_token())?;
177 let notes = validate_note_grant(client, owner_id, notes).await?;
178 let changed = client
179 .execute(
180 "UPDATE mcp_tokens SET scope=$3, notes_read=$4, notes_write=$5, notebook_ids=$6 \
181 WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL \
182 AND (expires_at IS NULL OR expires_at>NOW())",
183 &[
184 &id,
185 &owner_id,
186 &scope.as_str(),
187 ¬es.read,
188 ¬es.write,
189 ¬es.notebook_ids,
190 ],
191 )
192 .await
193 .map_err(AppError::query)?;
194 if changed == 0 {
195 return Err(invalid_token());
196 }
197 Ok(())
198}
199
200#[cfg(all(test, feature = "server"))]
201mod permission_tests {
202 use super::*;
203
204 #[tokio::test]
205 #[ignore = "requires DATABASE_URL; uses connection-local temporary tables"]
206 async fn edit_permissions_preserves_token_and_enforces_ownership_and_validity() {
207 let url = std::env::var("DATABASE_URL").unwrap();
208 let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
209 .await
210 .unwrap();
211 let connection = tokio::spawn(connection);
212 client.batch_execute(
213 "CREATE TEMP TABLE notebooks (id INT PRIMARY KEY, owner_id INT NOT NULL);
214 INSERT INTO notebooks VALUES (10,1),(20,2);
215 CREATE TEMP TABLE mcp_tokens (
216 id UUID PRIMARY KEY, user_id INT NOT NULL, scope TEXT NOT NULL,
217 notes_read BOOLEAN DEFAULT false, notes_write BOOLEAN DEFAULT false,
218 notebook_ids INT[], revoked_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
219 token_hash TEXT DEFAULT 'original-hash', token_enc TEXT DEFAULT 'original-ciphertext'
220 )"
221 ).await.unwrap();
222 let id = uuid::Uuid::new_v4();
223 client
224 .execute(
225 "INSERT INTO mcp_tokens(id,user_id,scope) VALUES($1,1,'admin')",
226 &[&id],
227 )
228 .await
229 .unwrap();
230 let grant = NoteGrant {
231 read: false,
232 write: true,
233 notebook_ids: Some(vec![10, 10]),
234 };
235 save_token_permissions(&client, 1, &id.to_string(), TokenScope::Read, grant)
236 .await
237 .unwrap();
238 let row = client
239 .query_one("SELECT * FROM mcp_tokens WHERE id=$1", &[&id])
240 .await
241 .unwrap();
242 assert!(row.get::<_, bool>("notes_read"));
243 assert!(row.get::<_, bool>("notes_write"));
244 assert_eq!(row.get::<_, Vec<i32>>("notebook_ids"), vec![10]);
245 assert_eq!(row.get::<_, String>("scope"), "read");
246 assert_eq!(row.get::<_, String>("token_hash"), "original-hash");
247 assert_eq!(row.get::<_, String>("token_enc"), "original-ciphertext");
248 assert!(row
249 .get::<_, Option<chrono::DateTime<chrono::Utc>>>("expires_at")
250 .is_none());
251
252 for ids in [vec![], vec![20], vec![10, 20], vec![999]] {
253 assert!(save_token_permissions(
254 &client,
255 1,
256 &id.to_string(),
257 TokenScope::Admin,
258 NoteGrant {
259 read: true,
260 write: false,
261 notebook_ids: Some(ids)
262 }
263 )
264 .await
265 .is_err());
266 }
267 for (owner, target) in [
268 (2, id.to_string()),
269 (1, uuid::Uuid::new_v4().to_string()),
270 (1, "invalid".into()),
271 ] {
272 assert!(save_token_permissions(
273 &client,
274 owner,
275 &target,
276 TokenScope::Admin,
277 NoteGrant::default()
278 )
279 .await
280 .is_err());
281 }
282 let row = client
283 .query_one(
284 "SELECT scope,notes_write,notebook_ids FROM mcp_tokens WHERE id=$1",
285 &[&id],
286 )
287 .await
288 .unwrap();
289 assert_eq!(row.get::<_, String>(0), "read");
290 assert!(row.get::<_, bool>(1));
291 assert_eq!(row.get::<_, Vec<i32>>(2), vec![10]);
292
293 save_token_permissions(
295 &client,
296 1,
297 &id.to_string(),
298 TokenScope::Write,
299 NoteGrant {
300 read: true,
301 write: false,
302 notebook_ids: None,
303 },
304 )
305 .await
306 .unwrap();
307 save_token_permissions(
308 &client,
309 1,
310 &id.to_string(),
311 TokenScope::Write,
312 NoteGrant {
313 read: false,
314 write: false,
315 notebook_ids: Some(vec![999]),
316 },
317 )
318 .await
319 .unwrap();
320 let row = client
321 .query_one(
322 "SELECT notes_read,notes_write,notebook_ids FROM mcp_tokens WHERE id=$1",
323 &[&id],
324 )
325 .await
326 .unwrap();
327 assert!(!row.get::<_, bool>(0));
328 assert!(!row.get::<_, bool>(1));
329 assert!(row.get::<_, Option<Vec<i32>>>(2).is_none());
330 for sql in [
331 "UPDATE mcp_tokens SET expires_at=NOW()-INTERVAL '1 second' WHERE id=$1",
332 "UPDATE mcp_tokens SET expires_at=NULL,revoked_at=NOW() WHERE id=$1",
333 ] {
334 client.execute(sql, &[&id]).await.unwrap();
335 assert!(save_token_permissions(
336 &client,
337 1,
338 &id.to_string(),
339 TokenScope::Admin,
340 NoteGrant::default()
341 )
342 .await
343 .is_err());
344 let scope: String = client
345 .query_one("SELECT scope FROM mcp_tokens WHERE id=$1", &[&id])
346 .await
347 .unwrap()
348 .get(0);
349 assert_eq!(scope, "write");
350 }
351 drop(client);
352 connection.await.unwrap().unwrap();
353 }
354}
355
356#[server(ListMcpTokens, endpoint = "list_mcp_tokens", input = leptos::server_fn::codec::Json)]
360pub async fn list_mcp_tokens() -> Result<Vec<McpTokenSummary>, ApiError> {
361 #[cfg(feature = "server")]
362 {
363 use crate::api::auth::get_current_admin_user;
364 use crate::api::error::AppError;
365 use crate::db::pool::get_conn;
366
367 let admin = get_current_admin_user().await?;
368 let client = get_conn().await.map_err(AppError::db_conn)?;
369
370 let rows = client
371 .query(
372 "SELECT id::text, user_id, name, scope, created_at, expires_at, \
373 last_used_at, revoked_at, notes_read, notes_write, notebook_ids \
374 FROM mcp_tokens \
375 WHERE user_id = $1 \
376 ORDER BY created_at DESC",
377 &[&admin.id],
378 )
379 .await
380 .map_err(AppError::query)?;
381
382 Ok(rows
383 .iter()
384 .map(row_to_mcp_token_meta)
385 .map(McpTokenSummary::from)
386 .collect())
387 }
388 #[cfg(not(feature = "server"))]
389 unreachable!()
390}
391
392#[server(RevealMcpToken, endpoint = "reveal_mcp_token", input = leptos::server_fn::codec::Json)]
397pub async fn reveal_mcp_token(id: String) -> Result<Option<String>, ApiError> {
398 #[cfg(feature = "server")]
399 {
400 use crate::api::auth::get_current_admin_user;
401 use crate::api::error::AppError;
402 use crate::db::pool::get_conn;
403 use crate::mcp::crypto::decrypt_token;
404
405 let admin = get_current_admin_user().await?;
406 let client = get_conn().await.map_err(AppError::db_conn)?;
407
408 let id = match uuid::Uuid::parse_str(&id) {
410 Ok(u) => u,
411 Err(_) => return Ok(None),
412 };
413
414 let row = client
416 .query_opt(
417 "SELECT token_enc FROM mcp_tokens WHERE id = $1::uuid AND user_id = $2",
418 &[&id, &admin.id],
419 )
420 .await
421 .map_err(AppError::query)?;
422
423 Ok(row
426 .map(|r| r.get::<_, String>("token_enc"))
427 .and_then(|enc| decrypt_token(&enc)))
428 }
429 #[cfg(not(feature = "server"))]
430 unreachable!()
431}
432
433#[server(RevokeMcpToken, endpoint = "revoke_mcp_token", input = leptos::server_fn::codec::Json)]
437pub async fn revoke_mcp_token(id: String) -> Result<(), ApiError> {
438 #[cfg(feature = "server")]
439 {
440 use crate::api::auth::get_current_admin_user;
441 use crate::api::error::AppError;
442 use crate::db::pool::get_conn;
443
444 let admin = get_current_admin_user().await?;
445 let client = get_conn().await.map_err(AppError::db_conn)?;
446
447 let id = match uuid::Uuid::parse_str(&id) {
449 Ok(u) => u,
450 Err(_) => return Ok(()),
451 };
452
453 client
454 .execute(
455 "UPDATE mcp_tokens SET revoked_at = NOW() \
456 WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL",
457 &[&id, &admin.id],
458 )
459 .await
460 .map_err(AppError::query)?;
461
462 Ok(())
463 }
464 #[cfg(not(feature = "server"))]
465 unreachable!()
466}
467
468#[cfg(feature = "server")]
473fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
474 let scope_str: String = row.get("scope");
475 let scope = TokenScope::from_db(&scope_str).unwrap_or_else(|| {
476 tracing::warn!(scope = %scope_str, "mcp_tokens.scope 非法值,兜底为 read");
477 TokenScope::Read
478 });
479 McpToken {
480 id: row.get("id"),
481 user_id: row.get("user_id"),
482 name: row.get("name"),
483 scope,
484 notes: crate::models::mcp_token::NoteGrant {
485 read: row.get("notes_read"),
486 write: row.get("notes_write"),
487 notebook_ids: row.get("notebook_ids"),
488 },
489 token_enc: String::new(),
490 token_hash: String::new(),
491 created_at: row.get("created_at"),
492 expires_at: row.get("expires_at"),
493 last_used_at: row.get("last_used_at"),
494 revoked_at: row.get("revoked_at"),
495 }
496}
497
498#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
504pub struct McpConfigSnippet {
505 pub title: String,
507 pub content: String,
509 pub content_html: String,
511}
512
513impl McpConfigSnippet {
514 pub fn agent_prompt(&self) -> String {
516 format!(
517 "请帮我接入 Yggdrasil MCP,目标客户端及配置位置为 {},\
518 配置内容为以下 JSON 字符串(请先解码字符串,再按原生格式合并):{};\
519 请先读取实际生效的配置,保留其他服务和设置,已有同名服务则更新而不重复添加,\
520 将令牌仅写入本地配置并避免提交到 Git 或在回复中展示,\
521 完成后验证连接及工具列表,若需重启或当前会话无法加载请明确告知。",
522 self.title,
523 serde_json::json!(self.content),
524 )
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
534pub struct McpClientConfigs {
535 pub agent_prompt: String,
537 pub snippets: Vec<McpConfigSnippet>,
539}
540
541#[server(GetMcpClientConfigs, endpoint = "get_mcp_client_configs", input = leptos::server_fn::codec::Json)]
546pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ApiError> {
547 #[cfg(feature = "server")]
548 {
549 use crate::api::auth::get_current_admin_user;
550 use crate::highlight::server::highlight_code;
551
552 let _admin = get_current_admin_user().await?;
553 let base_url = crate::api::settings::runtime_security_settings()
554 .await
555 .app_base_url;
556 let base_url = if base_url.trim().is_empty() {
557 crate::mcp::config::base_url_from_env()
558 } else {
559 base_url
560 };
561 let c = crate::mcp::config::generate_client_configs(&base_url, &token);
562 let entries: [(&str, String, &str); 8] = [
564 (
565 "Oh-My-Pi(项目根 .mcp.json / ~/.omp/agent/mcp.json 或 ~/.mcp.json)",
566 c.omp_json,
567 "json",
568 ),
569 (
570 "Codex(追加到 ~/.codex/config.toml 或项目根 .codex/config.toml)",
571 c.codex_toml,
572 "toml",
573 ),
574 (
575 "OpenCode(~/.config/opencode/opencode.json 或项目根 opencode.json)",
576 c.opencode_json,
577 "json",
578 ),
579 (
580 "Claude Code(.mcp.json / ~/.claude.json)",
581 c.claude_code_json,
582 "json",
583 ),
584 ("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
585 ("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
586 ("通用(单 server entry)", c.generic_json, "json"),
587 ("Claude Code CLI", c.claude_cli, "bash"),
588 ];
589 let snippets = entries
590 .into_iter()
591 .map(|(title, content, lang)| McpConfigSnippet {
592 title: title.to_string(),
593 content_html: highlight_code(&content, Some(lang)),
594 content,
595 })
596 .collect();
597 Ok(McpClientConfigs {
598 agent_prompt: c.agent_prompt,
599 snippets,
600 })
601 }
602 #[cfg(not(feature = "server"))]
603 unreachable!()
604}
605
606#[cfg(all(test, feature = "server"))]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn agent_prompt_preserves_client_config_when_decoded() {
612 let configs = crate::mcp::config::generate_client_configs(
613 "https://example.com/",
614 "ygg_fixture_\"quoted\\token\n",
615 );
616 for (title, content) in [
617 ("Codex(~/.codex/config.toml)", configs.codex_toml),
618 ("Oh-My-Pi(~/.omp/agent/mcp.json)", configs.omp_json),
619 ] {
620 let snippet = McpConfigSnippet {
621 title: title.into(),
622 content: content.clone(),
623 content_html: String::new(),
624 };
625 let prompt = snippet.agent_prompt();
626 assert!(!prompt.contains(['\n', '\r']));
627 assert!(prompt.contains(title));
628 let encoded = prompt
629 .split_once("(请先解码字符串,再按原生格式合并):")
630 .unwrap()
631 .1;
632 let decoded = serde_json::Deserializer::from_str(encoded)
633 .into_iter::<String>()
634 .next()
635 .unwrap()
636 .unwrap();
637 assert_eq!(decoded, content);
638 assert!(prompt.contains("保留其他服务和设置"));
639 assert!(prompt.contains("已有同名服务则更新而不重复添加"));
640 }
641 }
642
643 #[test]
644 fn lifetime_expires_at_days() {
645 let now = chrono::Utc::now();
646 let d1 = TokenLifetime::Days1.expires_at().unwrap();
647 let d7 = TokenLifetime::Days7.expires_at().unwrap();
648 assert!(d1 > now);
649 assert!(d7 > d1);
650 let delta = (d7 - d1).num_seconds() as f64 / 86400.0;
652 assert!((5.9..6.1).contains(&delta));
653 }
654
655 #[test]
656 fn lifetime_never_is_none() {
657 assert!(TokenLifetime::Never.expires_at().is_none());
658 }
659
660 #[test]
661 fn lifetime_serde_roundtrip() {
662 let json = serde_json::to_string(&TokenLifetime::Days30).unwrap();
663 assert_eq!(json, "\"days30\"");
664 let back: TokenLifetime = serde_json::from_str(&json).unwrap();
665 assert_eq!(back, TokenLifetime::Days30);
666 }
667}