1use crate::surface::{self, Endpoint, McpArguments};
21use serde_json::{json, Value};
22use std::io::{BufRead, Write};
23use std::path::Path;
24use std::process::{Command, Stdio};
25use std::sync::atomic::{AtomicU64, Ordering};
26
27pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
29pub const CATALOG_TTL_MS: u64 = 300_000;
31
32const SUPPORTED_VERSIONS: &[&str] = &[MODERN_PROTOCOL_VERSION, "2025-11-25", "2025-06-18"];
33const SERVER_NAME: &str = "choir-mcp";
34static BODY_FILE_ID: AtomicU64 = AtomicU64::new(0);
35
36#[derive(Clone)]
37struct Credentials {
38 user: String,
39 token: String,
40}
41
42pub fn credential_pair(path: &Path, selected: Option<&str>) -> Result<(String, String), String> {
54 read_credentials(path, selected).map(|found| (found.user, found.token))
55}
56
57#[derive(Clone)]
63pub struct HttpClient {
64 api: String,
65 credentials: Option<Credentials>,
66}
67
68impl HttpClient {
69 pub fn new(
79 api: &str,
80 auth_file: Option<&Path>,
81 auth_user: Option<&str>,
82 ) -> Result<Self, String> {
83 if !(api.starts_with("http://") || api.starts_with("https://")) {
84 return Err("<api> must start with http:// or https://".to_string());
85 }
86 if auth_user.is_some() && auth_file.is_none() {
87 return Err("--auth-user needs --auth-file".to_string());
88 }
89 let credentials = auth_file
90 .map(|path| read_credentials(path, auth_user))
91 .transpose()?;
92 Ok(Self {
93 api: api.trim_end_matches('/').to_string(),
94 credentials,
95 })
96 }
97
98 pub fn with_credential(api: &str, user: &str, token: &str) -> Result<Self, String> {
112 if !(api.starts_with("http://") || api.starts_with("https://")) {
113 return Err("<api> must start with http:// or https://".to_string());
114 }
115 Ok(Self {
116 api: api.trim_end_matches('/').to_string(),
117 credentials: Some(Credentials {
118 user: user.to_string(),
119 token: token.to_string(),
120 }),
121 })
122 }
123
124 pub fn request(&self, endpoint: &Endpoint, arguments: &Value) -> Result<(u16, String), String> {
134 self.call(endpoint, arguments)
135 .map(|response| (response.status, response.body))
136 }
137
138 pub fn get(&self, path: &str) -> Result<(u16, String), String> {
155 let mut command = Command::new("curl");
156 command.args(["-sS", "-w", "\n%{http_code}", "--config", "-", "-X", "GET"]);
157 command.arg(format!("{}{path}", self.api));
158 self.run(command).map(|r| (r.status, r.body))
159 }
160
161 fn call(&self, endpoint: &Endpoint, arguments: &Value) -> Result<HttpResponse, String> {
162 let object = arguments
163 .as_object()
164 .ok_or_else(|| "tool arguments must be a JSON object".to_string())?;
165 let base_path = endpoint.path.split('?').next().unwrap_or(endpoint.path);
166 let mut command = Command::new("curl");
167 command.args([
168 "-sS",
169 "-w",
170 "\n%{http_code}",
171 "--config",
172 "-",
173 "-X",
174 endpoint.method,
175 ]);
176
177 let arguments_shape = endpoint.mcp.as_ref().map_or(
186 if endpoint.method == "GET" {
187 McpArguments::Empty
188 } else {
189 McpArguments::Body
190 },
191 |tool| tool.arguments,
192 );
193 let body_file = match arguments_shape {
194 McpArguments::Empty => {
195 if !object.is_empty() {
196 return Err("this tool takes no arguments".to_string());
197 }
198 None
199 }
200 McpArguments::Body => {
201 let body = submission_body(endpoint, arguments);
202 let file = BodyFile::new(&body)?;
203 command.args(["-H", "Content-Type: application/json", "--data-binary"]);
204 command.arg(format!("@{}", file.path.display()));
205 Some(file)
206 }
207 McpArguments::Query { parameters } => {
208 let schema: Value =
214 serde_json::from_str(endpoint.mcp.as_ref().expect("MCP endpoint").input_schema)
215 .unwrap_or(Value::Null);
216 let required: Vec<&str> = schema["required"]
217 .as_array()
218 .map(|names| names.iter().filter_map(Value::as_str).collect())
219 .unwrap_or_default();
220 command.arg("--get");
221 for parameter in parameters {
222 let Some(value) = object.get(*parameter) else {
223 if required.contains(parameter) {
224 return Err(format!("missing `{parameter}` argument"));
225 }
226 continue;
227 };
228 let value = match value {
229 Value::String(value) => value.clone(),
230 Value::Number(value) if value.is_i64() || value.is_u64() => {
231 value.to_string()
232 }
233 _ => return Err(format!("`{parameter}` must be a string or integer")),
234 };
235 command.args(["--data-urlencode"]);
236 command.arg(format!("{parameter}={value}"));
237 }
238 None
239 }
240 };
241 command.arg(format!("{}{base_path}", self.api));
242 let response = self.run(command);
243 drop(body_file);
246 response
247 }
248
249 fn run(&self, mut command: Command) -> Result<HttpResponse, String> {
256 command
257 .stdin(Stdio::piped())
258 .stdout(Stdio::piped())
259 .stderr(Stdio::null());
262
263 let mut child = command
264 .spawn()
265 .map_err(|_| "could not start curl".to_string())?;
266 let config = self.curl_config();
267 child
268 .stdin
269 .take()
270 .expect("piped curl stdin")
271 .write_all(config.as_bytes())
272 .map_err(|_| "could not configure curl".to_string())?;
273 let output = child
274 .wait_with_output()
275 .map_err(|_| "could not wait for curl".to_string())?;
276 if !output.status.success() {
277 return Err(
286 "could not reach the choir node: check the URL, and that the node \
287 is running (operators on a tunnelled machine: the SSH forward may \
288 have expired)"
289 .to_string(),
290 );
291 }
292 let output = String::from_utf8(output.stdout)
293 .map_err(|_| "the choir node returned non-UTF-8 data".to_string())?;
294 let (body, status) = output
295 .rsplit_once('\n')
296 .ok_or_else(|| "curl returned no HTTP status".to_string())?;
297 let status = status
298 .trim()
299 .parse::<u16>()
300 .map_err(|_| "curl returned an invalid HTTP status".to_string())?;
301 Ok(HttpResponse {
302 status,
303 body: body.to_string(),
304 })
305 }
306
307 fn curl_config(&self) -> String {
308 self.credentials
309 .as_ref()
310 .map_or_else(String::new, |credentials| {
311 format!("user = \"{}:{}\"\n", credentials.user, credentials.token)
314 })
315 }
316}
317
318fn submission_body(endpoint: &Endpoint, arguments: &Value) -> Value {
325 let mut body = arguments.clone();
326 match endpoint.path {
327 "/api/submit" => add_channel_aliases(&mut body),
328 "/api/submit-batch" => {
329 if let Some(ops) = body.get_mut("ops").and_then(Value::as_array_mut) {
330 for op in ops {
331 add_channel_aliases(op);
332 }
333 }
334 }
335 _ => {}
336 }
337 body
338}
339
340fn add_channel_aliases(body: &mut Value) {
341 let Some(object) = body.as_object_mut() else {
342 return;
343 };
344 match (
345 object.get("channel").cloned(),
346 object.get("workspace").cloned(),
347 ) {
348 (Some(channel), None) => {
349 object.insert("workspace".to_string(), channel);
350 }
351 (None, Some(workspace)) => {
352 object.insert("channel".to_string(), workspace);
353 }
354 _ => {}
355 }
356}
357
358struct HttpResponse {
359 status: u16,
360 body: String,
361}
362
363struct BodyFile {
366 path: std::path::PathBuf,
367}
368
369impl BodyFile {
370 fn new(body: &Value) -> Result<Self, String> {
371 use std::fs::OpenOptions;
372 use std::io::Write as _;
373
374 for _ in 0..100 {
375 let id = BODY_FILE_ID.fetch_add(1, Ordering::Relaxed);
376 let path = std::env::temp_dir()
377 .join(format!("choir-mcp-body-{}-{id}.json", std::process::id()));
378 let mut options = OpenOptions::new();
379 options.write(true).create_new(true);
380 #[cfg(unix)]
381 {
382 use std::os::unix::fs::OpenOptionsExt;
383 options.mode(0o600);
384 }
385 match options.open(&path) {
386 Ok(mut file) => {
387 serde_json::to_writer(&mut file, body)
388 .map_err(|_| "could not encode tool arguments".to_string())?;
389 file.flush()
390 .map_err(|_| "could not write tool arguments".to_string())?;
391 return Ok(Self { path });
392 }
393 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
394 Err(_) => return Err("could not create a private request-body file".to_string()),
395 }
396 }
397 Err("could not allocate a unique request-body file".to_string())
398 }
399}
400
401impl Drop for BodyFile {
402 fn drop(&mut self) {
403 let _ = std::fs::remove_file(&self.path);
404 }
405}
406
407pub fn serve<R, W>(reader: R, mut writer: W, client: &HttpClient) -> std::io::Result<()>
417where
418 R: BufRead,
419 W: Write + Send,
420{
421 for line in reader.lines() {
422 let line = line?;
423 let response = std::thread::scope(|scope| {
424 scope
425 .spawn(|| dispatch_line(&line, client))
426 .join()
427 .map_err(|_| std::io::Error::other("MCP request thread panicked"))
428 })?;
429 if let Some(response) = response {
430 serde_json::to_writer(&mut writer, &response)?;
431 writer.write_all(b"\n")?;
432 writer.flush()?;
433 }
434 }
435 Ok(())
436}
437
438fn dispatch_line(line: &str, client: &HttpClient) -> Option<Value> {
439 let request: Value = match serde_json::from_str(line) {
440 Ok(request) => request,
441 Err(_) => return Some(error(Value::Null, -32700, "Parse error", None)),
442 };
443 dispatch(&request, client)
444}
445
446#[must_use]
451pub fn dispatch(request: &Value, client: &HttpClient) -> Option<Value> {
452 let Some(object) = request.as_object() else {
453 return Some(error(Value::Null, -32600, "Invalid Request", None));
454 };
455 let id = object.get("id").cloned();
456 let valid_id = id
457 .as_ref()
458 .is_none_or(|id| id.is_string() || id.is_number());
459 let method = object.get("method").and_then(Value::as_str);
460 if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || method.is_none() || !valid_id
461 {
462 return Some(error(
463 id.unwrap_or(Value::Null),
464 -32600,
465 "Invalid Request",
466 None,
467 ));
468 }
469 let method = method.expect("checked");
470 let Some(id) = id else {
471 return None;
475 };
476 let params = object.get("params").cloned().unwrap_or_else(|| json!({}));
477
478 if method == "initialize" {
479 let Some(version) = params.get("protocolVersion").and_then(Value::as_str) else {
480 return Some(error(id, -32602, "initialize needs protocolVersion", None));
481 };
482 return Some(success(
483 id,
484 complete(json!({
485 "protocolVersion": version,
486 "capabilities": { "tools": {} },
487 "serverInfo": server_info(),
488 "instructions": "Use signed operations for writes; git push is the compatibility path."
489 })),
490 ));
491 }
492
493 let modern = match modern_request(¶ms) {
494 Ok(modern) => modern,
495 Err(response) => return Some(with_id(response, id)),
496 };
497 match method {
498 "server/discover" => {
499 if !modern {
500 return Some(error(
501 id,
502 -32602,
503 "server/discover needs 2026-07-28 request metadata",
504 None,
505 ));
506 }
507 Some(success(
508 id,
509 complete(json!({
510 "supportedVersions": SUPPORTED_VERSIONS,
511 "capabilities": { "tools": {} },
512 "instructions": "Use signed operations for writes; git push is the compatibility path.",
513 "ttlMs": CATALOG_TTL_MS,
514 "cacheScope": "public"
515 })),
516 ))
517 }
518 "tools/list" => Some(success(
519 id,
520 complete(json!({
521 "tools": surface::mcp_tools(),
522 "ttlMs": CATALOG_TTL_MS,
523 "cacheScope": "public"
524 })),
525 )),
526 "tools/call" => Some(call_tool(id, ¶ms, client)),
527 _ => Some(error(id, -32601, "Method not found", None)),
528 }
529}
530
531fn modern_request(params: &Value) -> Result<bool, Value> {
532 let version = params
533 .get("_meta")
534 .and_then(|meta| meta.get("io.modelcontextprotocol/protocolVersion"));
535 let Some(version) = version else {
536 return Ok(false);
537 };
538 if version.as_str() != Some(MODERN_PROTOCOL_VERSION) {
539 return Err(error(
540 Value::Null,
541 -32022,
542 "Unsupported protocol version",
543 Some(json!({ "supported": SUPPORTED_VERSIONS })),
544 ));
545 }
546 let capabilities = params
547 .get("_meta")
548 .and_then(|meta| meta.get("io.modelcontextprotocol/clientCapabilities"));
549 if !capabilities.is_some_and(Value::is_object) {
550 return Err(error(
551 Value::Null,
552 -32602,
553 "2026-07-28 requests need clientCapabilities metadata",
554 None,
555 ));
556 }
557 Ok(true)
558}
559
560fn call_tool(id: Value, params: &Value, client: &HttpClient) -> Value {
561 let Some(name) = params.get("name").and_then(Value::as_str) else {
562 return error(id, -32602, "tools/call needs a tool name", None);
563 };
564 let Some(endpoint) = surface::mcp_endpoint(name) else {
565 return error(id, -32602, "Unknown tool", None);
566 };
567 let arguments = params
568 .get("arguments")
569 .cloned()
570 .unwrap_or_else(|| json!({}));
571 match client.call(endpoint, &arguments) {
572 Ok(response) => {
573 let structured_body = serde_json::from_str(&response.body)
574 .unwrap_or_else(|_| Value::String(response.body.clone()));
575 success(
576 id,
577 complete(json!({
578 "content": [{ "type": "text", "text": response.body }],
579 "structuredContent": {
580 "status": response.status,
581 "body": structured_body
582 },
583 "isError": !(200..300).contains(&response.status)
584 })),
585 )
586 }
587 Err(message) => success(
588 id,
589 complete(json!({
590 "content": [{ "type": "text", "text": message }],
591 "structuredContent": {
592 "status": null,
593 "error": "node_transport_failed"
594 },
595 "isError": true
596 })),
597 ),
598 }
599}
600
601fn complete(mut result: Value) -> Value {
602 result["resultType"] = Value::String("complete".to_string());
603 result["_meta"] = json!({ "io.modelcontextprotocol/serverInfo": server_info() });
604 result
605}
606
607fn server_info() -> Value {
608 json!({ "name": SERVER_NAME, "version": env!("CARGO_PKG_VERSION") })
609}
610
611fn success(id: Value, result: Value) -> Value {
612 json!({ "jsonrpc": "2.0", "id": id, "result": result })
613}
614
615fn error(id: Value, code: i64, message: &str, data: Option<Value>) -> Value {
616 let mut body = json!({
617 "jsonrpc": "2.0",
618 "id": id,
619 "error": { "code": code, "message": message }
620 });
621 if let Some(data) = data {
622 body["error"]["data"] = data;
623 }
624 body
625}
626
627fn with_id(mut response: Value, id: Value) -> Value {
628 response["id"] = id;
629 response
630}
631
632fn read_credentials(path: &Path, selected: Option<&str>) -> Result<Credentials, String> {
633 let text = std::fs::read_to_string(path).map_err(|_| "could not read auth file".to_string())?;
634 let mut credentials = Vec::new();
635 for line in text.lines() {
636 let line = line.trim();
637 if line.is_empty() || line.starts_with('#') {
638 continue;
639 }
640 let Some((user, token)) = line.split_once(':') else {
641 return Err("auth file lines must be user:token".to_string());
642 };
643 if user.is_empty()
644 || token.is_empty()
645 || user
646 .chars()
647 .chain(token.chars())
648 .any(|c| matches!(c, '\r' | '\n' | '"' | '\\'))
649 {
650 return Err("auth file contains an unsafe credential".to_string());
651 }
652 credentials.push(Credentials {
653 user: user.to_string(),
654 token: token.to_string(),
655 });
656 }
657 if let Some(selected) = selected {
658 let mut matches = credentials
659 .into_iter()
660 .filter(|entry| entry.user == selected);
661 let Some(found) = matches.next() else {
662 return Err("requested auth user was not found".to_string());
663 };
664 if matches.next().is_some() {
665 return Err("auth file contains the selected user more than once".to_string());
666 }
667 return Ok(found);
668 }
669 match credentials.as_slice() {
670 [only] => Ok(only.clone()),
671 [] => Err("auth file contains no credentials".to_string()),
672 _ => Err("auth file has multiple users; pass --auth-user".to_string()),
673 }
674}