Skip to main content

yggdrasil/mcp/
config.rs

1//! MCP 客户端配置片段生成。
2//!
3//! 不同客户端的配置文件格式不同,这里生成多种可直接复制粘贴的片段,全部指向
4//! 同一个 `/mcp` 端点、携带同一个 `Authorization: Bearer` 头。
5//!
6//! 形状来源:`docs/mcp-research.md` §"Client-config output format",各客户端官方文档
7//! (Claude Code / Cursor / Cline / Oh-My-Pi / OpenCode / Codex)核实。所有 JSON 都是 `serde_json`
8//! 构造再 pretty-print,保证格式合法(不会手抖写错逗号/引号)。
9
10use serde::Serialize;
11
12/// 各客户端配置 + 一个 CLI 命令。
13///
14/// 所有字段是可直接复制粘贴的最终字符串(JSON 已 pretty-print,CLI 是单行 shell)。
15/// `token` 形如 `ygg_...`,已嵌入各片段的 `Authorization` 头中。
16#[derive(Debug, Clone, Serialize)]
17pub struct ClientConfigs {
18    /// 一句话接入指令:由 Agent 识别当前客户端并合并原生配置。
19    pub agent_prompt: String,
20    /// Claude Code(`.mcp.json` / `~/.claude.json`)。注意 `type` 值是 `"http"`
21    ///(不是 `"streamable-http"`——Claude Code 用 `"streamable-http"` 会静默失败/卡在
22    /// "connecting",2026 官方文档明确要求 `http`)。字段:`type`,`url`,`headers.Authorization`。
23    pub claude_code_json: String,
24    /// Cursor(`~/.cursor/mcp.json`)。与 Claude Code 的关键差异:**不带 `type` 字段**——
25    /// Cursor 按远程 URL 自动识别 streamable-http;仅需 `url` + `headers.Authorization`。
26    pub cursor_json: String,
27    /// Cline(`cline_mcp_settings.json`)。`type: "streamableHttp"`(注意驼峰,非 `sse`),
28    /// 额外带 `disabled` / `autoApprove` 字段。
29    pub cline_json: String,
30    /// Oh-My-Pi(项目根 `.mcp.json` / 全局 `~/.omp/agent/mcp.json` 或 `~/.mcp.json`)。
31    /// omp 的协议字段是 `type: "http"`(与 Claude Code 同形),**不识别** `transport` /
32    /// `streamable-http`——后者会让 omp 退化为 stdio 并因缺 `command` 字段报错丢弃。
33    pub omp_json: String,
34    /// OpenCode(`opencode.json` 全局 `~/.config/opencode/opencode.json` / 项目根)。
35    /// 关键差异:schema 根键是 `mcp`(非 `mcpServers`),远程端点用 `type: "remote"`
36    ///(非 `streamable-http`),并带 `$schema` 与 `enabled` 字段(2026 opencode.ai 官方文档)。
37    pub opencode_json: String,
38    /// Codex(`~/.codex/config.toml` / 项目根 `.codex/config.toml`)。
39    /// Streamable HTTP 使用 `url` + `http_headers.Authorization`。
40    pub codex_toml: String,
41    /// 通用原始 JSON:一个 server entry 的纯净形式,供其它兼容客户端粘贴。
42    pub generic_json: String,
43    /// Claude Code CLI 一行命令:`claude mcp add --transport http <name> <url> --header ...`。
44    pub claude_cli: String,
45}
46
47/// `mcpServers` 条目里的 server 名(客户端侧的标识,与令牌 name 无关)。
48const SERVER_NAME: &str = "yggdrasil";
49
50/// 构造 `/mcp` 端点 URL:`base_url`(无尾斜杠) + `/mcp`。
51///
52/// `base_url` 来自 `APP_BASE_URL` 环境变量(调用方传入),形如 `https://rua.plus`。
53/// 这里只做最小拼接:去掉尾部斜杠再追加 `/mcp`,避免 `//mcp`。
54fn join_mcp_url(base_url: &str) -> String {
55    let trimmed = base_url.trim_end_matches('/');
56    format!("{trimmed}/mcp")
57}
58
59/// 生成各客户端配置 + CLI 命令。
60///
61/// - `base_url`:站点根 URL(形如 `https://rua.plus`),不带 `/mcp` 后缀。
62/// - `token`:明文 bearer 令牌(形如 `ygg_...`),会被嵌入 `Authorization` 头。
63pub fn generate_client_configs(base_url: &str, token: &str) -> ClientConfigs {
64    let mcp_url = join_mcp_url(base_url);
65    let auth_header = format!("Bearer {token}");
66
67    // --- Claude Code:type = "http"(非 "streamable-http",否则静默连接失败) ---
68    let claude_code_json = serde_json::json!({
69        "mcpServers": {
70            SERVER_NAME: {
71                "type": "http",
72                "url": mcp_url,
73                "headers": { "Authorization": auth_header }
74            }
75        }
76    });
77
78    // --- Cursor:不带 type 字段,按 URL 自动识别 streamable-http ---
79    let cursor_json = serde_json::json!({
80        "mcpServers": {
81            SERVER_NAME: {
82                "url": mcp_url,
83                "headers": { "Authorization": auth_header }
84            }
85        }
86    });
87
88    // --- Cline:type = "streamableHttp"(驼峰),带 disabled / autoApprove ---
89    let cline_json = serde_json::json!({
90        "mcpServers": {
91            SERVER_NAME: {
92                "type": "streamableHttp",
93                "url": mcp_url,
94                "headers": { "Authorization": auth_header },
95                "disabled": false,
96                "autoApprove": []
97            }
98        }
99    });
100    // --- Oh-My-Pi:type = "http"(与 Claude Code 同形)。omp 不识别 transport/streamable-http,
101    //     遇未知字段会退化为 stdio 并因缺 command 报错丢弃。与 Claude Code 的 JSON 体相同,
102    //     差异仅在配置文件路径(见上方字段文档)。
103    let omp_json = serde_json::json!({
104        "mcpServers": {
105            SERVER_NAME: {
106                "type": "http",
107                "url": mcp_url,
108                "headers": { "Authorization": auth_header }
109            }
110        }
111    });
112
113    // --- OpenCode:根键 mcp(非 mcpServers),remote 端点用 type: "remote"(非 streamable-http) ---
114    // 带 $schema 与 enabled 字段(opencode.ai 官方文档要求)。
115    let opencode_json = serde_json::json!({
116        "$schema": "https://opencode.ai/config.json",
117        "mcp": {
118            SERVER_NAME: {
119                "type": "remote",
120                "url": mcp_url,
121                "enabled": true,
122                "headers": { "Authorization": auth_header }
123            }
124        }
125    });
126
127    // Codex 官方文档:https://developers.openai.com/codex/mcp
128    // JSON 字符串的引号与转义也适用于 TOML 基本字符串,复用 serde_json 避免直接插值。
129    let codex_toml = format!(
130        "[mcp_servers.{SERVER_NAME}]\nurl = {}\nhttp_headers = {{ Authorization = {} }}",
131        serde_json::json!(mcp_url),
132        serde_json::json!(auth_header),
133    );
134
135    // --- 通用:单个 server entry 的纯净形式 ---
136    let generic_json = serde_json::json!({
137        "type": "streamable-http",
138        "url": mcp_url,
139        "headers": { "Authorization": auth_header }
140    });
141
142    // --- Claude Code CLI 一行命令 ---
143    // 注意 header 值用双引号包裹(含空格);shell 安全起见整个 header 用双引号。
144    let claude_cli = format!(
145        "claude mcp add --transport http {SERVER_NAME} {mcp_url} \\\n  --header \"Authorization: Bearer {token}\""
146    );
147
148    ClientConfigs {
149        agent_prompt: format!(
150            "请为你当前使用的客户端接入名为 {SERVER_NAME} 的 Yggdrasil MCP 服务,\
151             使用 Streamable HTTP,端点为 {},Authorization 请求头为 {};\
152             请先识别客户端并读取其实际生效的 MCP 配置,按该客户端的原生格式合并此服务,\
153             保留其他服务和设置,已有同名服务则更新而不重复添加,\
154             将令牌仅写入本地配置并避免提交到 Git 或在回复中展示,\
155             完成后验证连接及工具列表,若需重启或当前会话无法加载请明确告知。",
156            serde_json::json!(mcp_url),
157            serde_json::json!(auth_header),
158        ),
159        claude_code_json: pretty_json(&claude_code_json),
160        cursor_json: pretty_json(&cursor_json),
161        cline_json: pretty_json(&cline_json),
162        omp_json: pretty_json(&omp_json),
163        opencode_json: pretty_json(&opencode_json),
164        codex_toml,
165        generic_json: pretty_json(&generic_json),
166        claude_cli,
167    }
168}
169
170/// `serde_json::Value` → 缩进 2 空格的 pretty JSON 字符串。
171fn pretty_json(v: &serde_json::Value) -> String {
172    // 缩进 2 空格与各客户端文档示例一致;序列化不会失败(值来自 json! 宏)。
173    serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string())
174}
175
176/// 读取 `APP_BASE_URL` 环境变量作为站点根 URL;缺失时回退到本地开发地址。
177///
178/// 由 UI 调用方使用,保证「未设置环境变量」时仍能展示一个可用(本地)配置。
179pub fn base_url_from_env() -> String {
180    std::env::var("APP_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    const TOKEN: &str = "ygg_abcdef0123456789";
188    const BASE: &str = "https://rua.plus";
189
190    #[test]
191    fn join_url_handles_trailing_slash() {
192        assert_eq!(join_mcp_url("https://rua.plus/"), "https://rua.plus/mcp");
193        assert_eq!(join_mcp_url("https://rua.plus"), "https://rua.plus/mcp");
194        assert_eq!(join_mcp_url("https://rua.plus///"), "https://rua.plus/mcp");
195    }
196
197    #[test]
198    fn agent_prompt_carries_connection_details_on_one_line() {
199        let cfg = generate_client_configs("https://rua.plus/", "ygg_\"test\\token\n");
200        assert!(cfg.agent_prompt.contains("\"https://rua.plus/mcp\""));
201        assert!(cfg
202            .agent_prompt
203            .contains("\"Bearer ygg_\\\"test\\\\token\\n\""));
204        assert!(!cfg.agent_prompt.contains(['\n', '\r']));
205        assert!(cfg.agent_prompt.contains("保留其他服务和设置"));
206        assert!(cfg.agent_prompt.contains("验证连接及工具列表"));
207    }
208
209    #[test]
210    fn claude_code_json_is_valid_and_carries_bearer() {
211        let cfg = generate_client_configs(BASE, TOKEN);
212        let v: serde_json::Value = serde_json::from_str(&cfg.claude_code_json).unwrap();
213        assert_eq!(
214            v["mcpServers"]["yggdrasil"]["headers"]["Authorization"],
215            format!("Bearer {TOKEN}")
216        );
217        assert_eq!(v["mcpServers"]["yggdrasil"]["type"], "http"); // 非 "streamable-http"(会静默失败)
218        assert_eq!(v["mcpServers"]["yggdrasil"]["url"], "https://rua.plus/mcp");
219    }
220
221    #[test]
222    fn cursor_json_has_no_type_field() {
223        let cfg = generate_client_configs(BASE, TOKEN);
224        let v: serde_json::Value = serde_json::from_str(&cfg.cursor_json).unwrap();
225        let entry = &v["mcpServers"]["yggdrasil"];
226        // Cursor 按 URL 自动识别远程端点,不带 type 字段。
227        assert!(entry.get("type").is_none(), "cursor 配置不应含 type 字段");
228        assert_eq!(entry["url"], "https://rua.plus/mcp");
229        assert_eq!(entry["headers"]["Authorization"], format!("Bearer {TOKEN}"));
230    }
231
232    #[test]
233    fn cline_json_uses_streamable_http_camelcase_and_extra_fields() {
234        let cfg = generate_client_configs(BASE, TOKEN);
235        let v: serde_json::Value = serde_json::from_str(&cfg.cline_json).unwrap();
236        let entry = &v["mcpServers"]["yggdrasil"];
237        assert_eq!(entry["type"], "streamableHttp"); // 驼峰,非 streamable-http
238        assert_eq!(entry["disabled"], false);
239        assert_eq!(entry["autoApprove"], serde_json::json!([]));
240        assert_eq!(entry["headers"]["Authorization"], format!("Bearer {TOKEN}"));
241    }
242
243    #[test]
244    fn generic_json_is_bare_entry() {
245        let cfg = generate_client_configs(BASE, TOKEN);
246        let v: serde_json::Value = serde_json::from_str(&cfg.generic_json).unwrap();
247        assert!(
248            v.get("mcpServers").is_none(),
249            "generic 应是单个 entry,无 mcpServers 外层"
250        );
251        assert_eq!(v["type"], "streamable-http");
252        assert_eq!(v["url"], "https://rua.plus/mcp");
253    }
254
255    #[test]
256    fn omp_json_uses_type_http_not_transport() {
257        let cfg = generate_client_configs(BASE, TOKEN);
258        let v: serde_json::Value = serde_json::from_str(&cfg.omp_json).unwrap();
259        let entry = &v["mcpServers"]["yggdrasil"];
260        // omp 协议字段是 type: "http"(与 Claude Code 同形)。
261        assert_eq!(entry["type"], "http");
262        // 不应含 transport 字段——会让 omp 退化为 stdio 报错。
263        assert!(
264            entry.get("transport").is_none(),
265            "omp 配置不应含 transport 字段"
266        );
267        assert_eq!(entry["url"], "https://rua.plus/mcp");
268        assert_eq!(entry["headers"]["Authorization"], format!("Bearer {TOKEN}"));
269    }
270
271    #[test]
272    fn opencode_json_uses_mcp_root_key_and_remote_type() {
273        let cfg = generate_client_configs(BASE, TOKEN);
274        let v: serde_json::Value = serde_json::from_str(&cfg.opencode_json).unwrap();
275        // 关键差异:根键是 mcp(非 mcpServers)。
276        assert!(
277            v.get("mcpServers").is_none(),
278            "opencode 配置不应含 mcpServers 键"
279        );
280        let entry = &v["mcp"]["yggdrasil"];
281        // 远程端点用 type: "remote"(非 streamable-http)。
282        assert_eq!(entry["type"], "remote");
283        assert_eq!(entry["enabled"], true);
284        assert_eq!(v["$schema"], "https://opencode.ai/config.json");
285        assert_eq!(entry["url"], "https://rua.plus/mcp");
286        assert_eq!(entry["headers"]["Authorization"], format!("Bearer {TOKEN}"));
287    }
288
289    #[test]
290    fn claude_cli_one_liner_contains_url_and_header() {
291        let cfg = generate_client_configs(BASE, TOKEN);
292        assert!(cfg.claude_cli.contains("claude mcp add --transport http"));
293        assert!(cfg.claude_cli.contains("https://rua.plus/mcp"));
294        assert!(cfg.claude_cli.contains(&format!("Bearer {TOKEN}")));
295    }
296
297    #[test]
298    fn codex_toml_carries_endpoint_and_escaped_bearer() {
299        let cfg = generate_client_configs("https://rua.plus/", "ygg_\"test\\token\n");
300        assert_eq!(
301            cfg.codex_toml,
302            "[mcp_servers.yggdrasil]\nurl = \"https://rua.plus/mcp\"\nhttp_headers = { Authorization = \"Bearer ygg_\\\"test\\\\token\\n\" }"
303        );
304    }
305
306    #[test]
307    fn all_json_is_pretty_indented() {
308        let cfg = generate_client_configs(BASE, TOKEN);
309        for s in [
310            &cfg.claude_code_json,
311            &cfg.cursor_json,
312            &cfg.cline_json,
313            &cfg.omp_json,
314            &cfg.opencode_json,
315            &cfg.generic_json,
316        ] {
317            assert!(s.contains('\n'), "JSON 应是 pretty-printed: {s}");
318            assert!(s.contains("  "), "JSON 应含 2 空格缩进: {s}");
319        }
320    }
321}