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