Skip to main content

yggdrasil/api/
mcp_tokens.rs

1//! MCP 访问令牌管理:Leptos server functions。
2//!
3//! 管理员在后台 `/admin/mcp` 签发/查看/编辑权限/撤销为 AI 客户端(Claude Code / Cursor /
4//! Cline)准备的 bearer 令牌。明文 token 仅在签发与「重新查看」时返回给管理员,
5//! 数据库只存 AES-GCM 密文(`token_enc`,可解密重查)+ SHA-256 哈希(`token_hash`,
6//! 每请求 O(1) 常量查找,见 `src/mcp/auth.rs`)。
7//!
8//! 鉴权走 cookie session(`get_current_admin_user`),与其它后台 server-fn 一致;
9//! MCP 工具路径(bearer)无法调用这些 server-fn——那是 `src/mcp/tools/*` 的职责。
10
11#![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/// 令牌有效期预设:管理员在 UI 上从下拉菜单选择。
21///
22/// 序列化形式供前端选择回传(`days1` / `days7` / `days30` / `days90` / `never`)。
23/// `Never` 对应 `expires_at = NULL`(长期令牌);其余按当前时间 + N 天计算。
24#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
25#[serde(rename_all = "lowercase")]
26pub enum TokenLifetime {
27    /// 1 天(默认推荐:最小权限、轮换友好)。
28    Days1,
29    /// 7 天。
30    Days7,
31    /// 30 天。
32    Days30,
33    /// 90 天。
34    Days90,
35    /// 永不过期(`expires_at = NULL`)。仅用于可信长期客户端。
36    Never,
37}
38
39impl TokenLifetime {
40    /// 计算签发时刻对应的过期时间戳(UTC)。`Never` 返回 `None`。
41    #[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/// 签发新的 MCP 令牌。
55///
56/// 生成明文 `ygg_<32 hex>`,AES-GCM 加密后存密文 + SHA-256 哈希;明文随响应一次性
57/// 返回给管理员(后续可经 [`reveal_mcp_token`] 重新查看)。仅 admin。
58#[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        // 名称规范化与校验:去空白后非空,限制长度。
76        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        // 加密主密钥必须已配置,否则无法安全存储明文。
81        if crate::mcp::crypto::mcp_enc_key().is_none() {
82            return Err(AppError::Internal("MCP_TOKEN_ENC_KEY 未设置").into());
83        }
84
85        // 明文 token:`ygg_` + 32 字节随机数 hex(64 hex 字符)。
86        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, &notes.read, &notes.write, &notes.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/// 修改本人名下有效令牌的权限,不轮换密钥,也不修改有效期。
123#[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                &notes.read,
188                &notes.write,
189                &notes.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        // 改为全部笔记本,然后收回笔记访问;关闭授权时清除无效的旧范围。
294        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/// 列出当前管理员名下的全部令牌(不含任何密钥材料,仅展示用元数据)。
357///
358/// 按 `created_at DESC` 排序,最近签发的在前。仅 admin。
359#[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/// 重新查看令牌明文(可多次调用:明文以密文形式落库,可解密还原)。
393///
394/// 找不到令牌、或令牌不属于当前管理员 → 返回 `None`(不区分原因,避免探测)。
395/// 仅 admin。
396#[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        // id 由前端以字符串传入(表 id 列是 uuid):解析失败视作令牌不存在。
409        let id = match uuid::Uuid::parse_str(&id) {
410            Ok(u) => u,
411            Err(_) => return Ok(None),
412        };
413
414        // 仅取属于当前管理员的令牌的密文,避免越权解密他人令牌。
415        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        // 解密失败(密钥缺失/密文被篡改)也归一到 None:调用方无法区分,
424        // 按「该令牌不可解密」处理(等同于失效)。
425        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/// 撤销令牌(软删除:置 `revoked_at = now()`,行保留以备审计)。
434///
435/// 找不到或非本人令牌 → 静默无操作(不报错,避免探测)。仅 admin。
436#[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        // id 解析失败视作令牌不存在(静默无操作,避免探测)。
448        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/// 把数据库行解析为令牌元数据(不含明文;密文/哈希已 `#[serde(skip)]`,这里置空)。
469///
470/// `scope` 列存的是字符串;非法值(理论不可能,除非手工改库)按 read 兜底并记日志,
471/// 不 panic。
472#[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/// 单个客户端配置片段:标题 + 原始文本(供复制)+ syntect 高亮 HTML(供展示)。
499///
500/// 高亮 HTML 由 `crate::highlight::server::highlight_code` 生成(spaced CSS class 风格,
501/// 配合 `public/highlight.css`);前端需将其置于 `.md-content pre code` 作用域下,
502/// 否则高亮 CSS 选择器不匹配(见 `src/bin/generate_highlight_css.rs` 的 base 重写)。
503#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
504pub struct McpConfigSnippet {
505    /// 显示标题(含客户端名与目标文件路径)。
506    pub title: String,
507    /// 原始配置文本(供「复制」按钮复制,未高亮)。
508    pub content: String,
509    /// syntect 高亮后的 HTML(`<span>` 序列,无 `<pre>/<code>` 外壳)。
510    pub content_html: String,
511}
512
513impl McpConfigSnippet {
514    /// 将原始配置作为 JSON 字符串嵌入一句话,保留 TOML 换行及特殊字符。
515    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/// 各客户端 MCP 配置片段集合。
529///
530/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
531/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控);这里把每个片段的
532/// 原始文本与高亮 HTML 打包为可两端共享的 DTO,让 WASM 前端单次请求即可渲染带高亮的配置块。
533#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
534pub struct McpClientConfigs {
535    /// 携带端点与令牌、适配当前 Agent 所用客户端的一句话指令。
536    pub agent_prompt: String,
537    /// 有序配置片段(标题、原始文本、高亮 HTML)。
538    pub snippets: Vec<McpConfigSnippet>,
539}
540
541/// 根据明文令牌生成各客户端配置片段(含 syntect 高亮 HTML)。
542///
543/// 配置生成与高亮均在服务端完成(`crate::mcp::config` / `crate::highlight` 均为
544/// server-only),返回给前端展示。`APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
545#[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        // (标题, 内容, 语言):按客户端配置格式选择语法高亮。
563        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        // 7 天与 1 天的差应≈6 天(容忍微量时钟漂移)。
651        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}