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