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 generic_json: String,
38 pub claude_cli: String,
40}
41
42const SERVER_NAME: &str = "yggdrasil";
44
45fn join_mcp_url(base_url: &str) -> String {
50 let trimmed = base_url.trim_end_matches('/');
51 format!("{trimmed}/mcp")
52}
53
54pub 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 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 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 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 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 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 let generic_json = serde_json::json!({
124 "type": "streamable-http",
125 "url": mcp_url,
126 "headers": { "Authorization": auth_header }
127 });
128
129 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
146fn pretty_json(v: &serde_json::Value) -> String {
148 serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string())
150}
151
152pub 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"); 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 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"); 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 assert_eq!(entry["type"], "http");
226 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 assert!(
241 v.get("mcpServers").is_none(),
242 "opencode 配置不应含 mcpServers 键"
243 );
244 let entry = &v["mcp"]["yggdrasil"];
245 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}