Skip to main content

choir_cli/
mcp.rs

1//! Synchronous stdio MCP adapter over a choir node's HTTP API.
2//!
3//! The adapter owns no platform state and implements no platform policy.
4//! Every tool call shells out to `curl` and returns the node's exact body;
5//! [`crate::surface::ENDPOINTS`] supplies both the tool catalog and the
6//! HTTP route, so the two surfaces cannot drift.
7//!
8//! Legacy 2025 clients use `initialize`; modern 2026-07-28 clients carry
9//! their protocol version and capabilities in each request's `_meta`.
10//! Neither path creates a session. One synchronous OS thread handles each
11//! newline-delimited request, and no async runtime is involved.
12//!
13//! # Examples
14//!
15//! ```
16//! let tools = choir_cli::surface::mcp_tools();
17//! assert_eq!(tools[0]["name"], "choir_submit");
18//! ```
19
20use 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
27/// Modern stateless protocol revision served by this adapter.
28pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
29/// Cache lifetime for the immutable-per-process discovery and tool lists.
30pub 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
42/// Reads one `user:token` pair from an auth file, for a caller that
43/// needs the credential itself rather than a request carrying it.
44///
45/// The git credential helper is that caller and the only one: git asks
46/// for a username and a password on stdout, so the pair cannot be kept
47/// inside a request the way every other use here keeps it.
48///
49/// # Errors
50///
51/// Returns a description when the file is unreadable, malformed, empty,
52/// or holds several credentials with none selected.
53pub 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/// Blocking HTTP client used by MCP tool calls.
58///
59/// It shells out to `curl`, matching the repository's outbound-HTTP
60/// convention. Credentials, when configured, are read from a named file
61/// and sent to curl over stdin rather than placed in its argument vector.
62#[derive(Clone)]
63pub struct HttpClient {
64    api: String,
65    credentials: Option<Credentials>,
66}
67
68impl HttpClient {
69    /// Builds a client for `api`.
70    ///
71    /// `auth_file` uses the node's `user:token`-per-line format. When it
72    /// contains more than one credential, `auth_user` must select one.
73    ///
74    /// # Errors
75    ///
76    /// Returns a description for an invalid URL, unreadable or malformed
77    /// auth file, or an ambiguous credential selection.
78    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    /// Builds a client for `api` from a credential already in hand.
99    ///
100    /// The invite in a join link is the one credential this crate never
101    /// reads from a file: it arrives inside a URL somebody was sent in a
102    /// chat window. Writing it to a temporary file first so that
103    /// [`HttpClient::new`] could read it back would put a live secret on
104    /// disk for the length of one request, which is exactly the shape
105    /// this constructor exists to avoid. It still reaches `curl` over
106    /// stdin, never on an argv.
107    ///
108    /// # Errors
109    ///
110    /// Returns a description when `api` is not an http(s) URL.
111    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    /// Sends one request described by the shared endpoint table.
125    ///
126    /// This is also used by the ordinary CLI so its authenticated HTTP
127    /// path and the MCP adapter cannot diverge on credential handling.
128    ///
129    /// # Errors
130    ///
131    /// Returns a transport or argument-validation description without
132    /// reflecting credentials or the node address.
133    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    /// Sends a bare authenticated `GET` to one path on the node.
139    ///
140    /// For the operational endpoints that are not part of the tool
141    /// table: `/healthz` and its neighbours are authenticated like
142    /// everything else, but they carry no arguments and no MCP schema,
143    /// so they cannot be reached through [`request`]. Going through this
144    /// client rather than a fresh `curl` keeps one credential path in
145    /// the crate — the config-on-stdin that keeps a token off argv,
146    /// where `ps` would show it.
147    ///
148    /// # Errors
149    ///
150    /// Returns a transport description, without reflecting the
151    /// credential or the node address.
152    ///
153    /// [`request`]: HttpClient::request
154    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        // `None` is an endpoint outside the MCP surface, reached by the
178        // CLI and never by an agent — the accounts roster and the
179        // credential endpoints are the cases. A read there takes
180        // arguments exactly the way an `Empty` tool does; a write takes
181        // a JSON body, the way every other write on this node does.
182        // Deriving it from the method rather than listing paths means a
183        // credential endpoint stays off the agent surface without also
184        // having to be a second request shape.
185        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                // Which of these may be omitted comes from the endpoint's
209                // own input schema rather than a second list kept here.
210                // `limit` and `offset` are optional everywhere they
211                // appear and `reviewer` and `from` are not, and stating
212                // that twice is how the two copies come to disagree.
213                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        // Held until curl has exited: the body is a file curl reads, and
244        // dropping it earlier would delete the request out from under it.
245        drop(body_file);
246        response
247    }
248
249    /// Spawns a prepared `curl`, feeds it the credential on stdin, and
250    /// parses the status the `-w` format smuggled onto stdout.
251    ///
252    /// Every request in this crate ends here, which is the point: the
253    /// credential reaches curl exactly one way, and a caller cannot
254    /// accidentally put a token on argv where `ps` would show it.
255    fn run(&self, mut command: Command) -> Result<HttpResponse, String> {
256        command
257            .stdin(Stdio::piped())
258            .stdout(Stdio::piped())
259            // Curl errors can include private node addresses. The MCP
260            // result names the transport failure without reflecting them.
261            .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            // Name the likeliest fix, not just the failure. Two
278            // readers reach this line and they are not the same person:
279            // whoever typed the URL, who needs to check it and the
280            // node, and the operator on a tunnelled machine, whose
281            // usual cause is an expired SSH forward -- measured, from
282            // retyping the same command at a dead port three times.
283            // This used to address only the second, and sent everyone
284            // else to a script they do not have.
285            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                // read_credentials rejects curl-config metacharacters, so
312                // this quoted line cannot grow a second config directive.
313                format!("user = \"{}:{}\"\n", credentials.user, credentials.token)
314            })
315    }
316}
317
318/// Adds the frozen v1 submission spelling alongside the current name.
319///
320/// During a rolling upgrade an updated CLI or MCP adapter can still be
321/// talking to a node that only reads `workspace`. Current nodes accept
322/// both names when their values agree. Preserve a caller's conflicting
323/// pair so the node rejects it rather than silently choosing a scope.
324fn 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
363/// Private request-body file passed to curl by path, keeping large batches
364/// and signed payloads out of the process argument vector.
365struct 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
407/// Serves newline-delimited JSON-RPC until `reader` reaches EOF.
408///
409/// Each message is handled on a fresh synchronous OS thread. Responses
410/// are flushed one per line; notifications produce no output.
411///
412/// # Errors
413///
414/// Returns an I/O error when stdin cannot be read, stdout cannot be
415/// written, or a request thread panics.
416pub 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/// Dispatches one decoded JSON-RPC message.
447///
448/// Notifications return `None`; requests return a JSON-RPC result or
449/// error response. This function stores no negotiated-version state.
450#[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        // The only notification observed from the supported clients is
472        // notifications/initialized. Unknown notifications also have no
473        // JSON-RPC response, as required by the base protocol.
474        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(&params) {
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, &params, 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}