1use serde::Serialize;
11
12#[derive(Debug, Clone, Serialize)]
17pub struct ClientConfigs {
18 pub agent_prompt: String,
20 pub claude_code_json: String,
24 pub cursor_json: String,
27 pub cline_json: String,
30 pub omp_json: String,
34 pub opencode_json: String,
38 pub codex_toml: String,
41 pub generic_json: String,
43 pub claude_cli: String,
45}
46
47const SERVER_NAME: &str = "yggdrasil";
49
50fn join_mcp_url(base_url: &str) -> String {
55 let trimmed = base_url.trim_end_matches('/');
56 format!("{trimmed}/mcp")
57}
58
59pub 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 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 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 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 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 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 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 let generic_json = serde_json::json!({
137 "type": "streamable-http",
138 "url": mcp_url,
139 "headers": { "Authorization": auth_header }
140 });
141
142 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
170fn pretty_json(v: &serde_json::Value) -> String {
172 serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string())
174}
175
176pub 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"); 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 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"); 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 assert_eq!(entry["type"], "http");
262 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 assert!(
277 v.get("mcpServers").is_none(),
278 "opencode 配置不应含 mcpServers 键"
279 );
280 let entry = &v["mcp"]["yggdrasil"];
281 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}