1use serde::Serialize;
11
12#[derive(Debug, Clone, Serialize)]
17pub struct ClientConfigs {
18 pub claude_code_json: String,
22 pub cursor_json: String,
25 pub cline_json: String,
28 pub omp_json: String,
32 pub opencode_json: String,
36 pub codex_toml: String,
39 pub generic_json: String,
41 pub claude_cli: String,
43}
44
45const SERVER_NAME: &str = "yggdrasil";
47
48fn join_mcp_url(base_url: &str) -> String {
53 let trimmed = base_url.trim_end_matches('/');
54 format!("{trimmed}/mcp")
55}
56
57pub 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 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 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 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 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 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 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 let generic_json = serde_json::json!({
135 "type": "streamable-http",
136 "url": mcp_url,
137 "headers": { "Authorization": auth_header }
138 });
139
140 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
158fn pretty_json(v: &serde_json::Value) -> String {
160 serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string())
162}
163
164pub 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"); 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 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"); 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 assert_eq!(entry["type"], "http");
238 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 assert!(
253 v.get("mcpServers").is_none(),
254 "opencode 配置不应含 mcpServers 键"
255 );
256 let entry = &v["mcp"]["yggdrasil"];
257 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}