Skip to main content

omgbase_sync/
mcp_client.rs

1//! The MCP engine client (`spec/sync/README.md` §6; the reference's
2//! `McpEngineClient` in `@omgbase/sync`): reaches "the omgbase side" over
3//! the Model Context Protocol, so the [`Coordinator`](crate::Coordinator)
4//! runs unchanged against a remote engine, and the CLI's `--server` mode
5//! calls any catalog tool (`spec/surface` §4) and renders the result it
6//! would have computed locally.
7//!
8//! Two transports, one JSON-RPC 2.0 client on top:
9//!
10//! - **stdio** — spawn a command (`omg mcp -C /vault`, `omgbase mcp …`) and
11//!   speak newline-delimited JSON-RPC over its stdin/stdout (the framing of
12//!   [`omgbase mcp`](https://github.com/omgbase/omgbase) and of the SDK's
13//!   `StdioServerTransport`); stderr is inherited.
14//! - **Streamable HTTP** (feature `http`) — `POST` each message to the URL
15//!   with `Accept: application/json, text/event-stream`, read the reply from
16//!   a JSON body or an SSE stream, round-trip `Mcp-Session-Id`, and send
17//!   `MCP-Protocol-Version` once negotiated; extra headers ride on every
18//!   request (auth beyond a secret path); `DELETE` ends the session.
19//!
20//! The one rule for a `--server` value lives in [`parse_engine_spec`]: an
21//! `http(s)://` value is an HTTP endpoint (used verbatim, base path and
22//! all), anything else is whitespace-split and spawned — a URL is never
23//! spawned. Synchronous like the rest of the crate: one request in flight,
24//! a bounded wait per reply.
25
26use std::collections::BTreeMap;
27use std::io::{BufRead, BufReader, Write};
28use std::process::{Child, ChildStdin, Command, Stdio};
29use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
30use std::thread::JoinHandle;
31use std::time::{Duration, Instant};
32
33use omgbase_format::hash::{hex, sha256};
34use omgbase_store::{ChangesPage, CommitDigest, DeleteOutcome, DigestRevision, ObserveOutcome};
35use serde_json::{Map, Value, json};
36
37use crate::engine::{DocBytes, EngineClient, FileBytes};
38use crate::error::{Error, Result};
39
40/// The MCP protocol revision this client proposes in `initialize` (the
41/// server may answer with the one it speaks; the reply is what is sent as
42/// `MCP-Protocol-Version` afterwards).
43pub const PROTOCOL_VERSION: &str = "2025-03-26";
44
45/// `clientInfo.name` (the reference's).
46pub const CLIENT_NAME: &str = "omgbase-sync";
47
48/// How long one request may wait for its reply (the SDK's default request
49/// timeout).
50pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
51
52/// After stdin EOF a spawned server gets this long to exit before it is
53/// killed.
54const EXIT_GRACE: Duration = Duration::from_secs(5);
55
56// ---- the spec ---------------------------------------------------------------------------
57
58/// Where a `--server` value points.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum EngineSpec {
61    /// An http(s) endpoint reached over Streamable HTTP, with extra request
62    /// headers (`-H "Name: value"`, in flag order).
63    Http {
64        url: String,
65        headers: Vec<(String, String)>,
66    },
67    /// A command to spawn and speak stdio to.
68    Stdio { command: String, args: Vec<String> },
69}
70
71/// Whether `s` starts with `http://` or `https://` (case-insensitive).
72#[must_use]
73pub fn is_http_url(s: &str) -> bool {
74    let lower: String = s.chars().take(8).collect::<String>().to_ascii_lowercase();
75    lower.starts_with("http://") || lower.starts_with("https://")
76}
77
78/// Classify a raw `--server` value (the reference's `parseEngineSpec`): an
79/// `http(s)://` value is an HTTP endpoint (verbatim; `headers` ride along);
80/// anything else is split on whitespace and spawned. Errors: an empty
81/// value, or headers for a command (they have nowhere to go).
82pub fn parse_engine_spec(raw: &str, headers: &[(String, String)]) -> Result<EngineSpec> {
83    let spec = raw.trim();
84    if is_http_url(spec) {
85        return Ok(EngineSpec::Http {
86            url: spec.to_owned(),
87            headers: headers.to_vec(),
88        });
89    }
90    if !headers.is_empty() {
91        return Err(Error::Other(
92            "extra headers only apply to an http(s) server url, not a command to spawn".to_owned(),
93        ));
94    }
95    let mut argv = spec.split_whitespace().map(str::to_owned);
96    let Some(command) = argv.next() else {
97        return Err(Error::Other(
98            "server spec is empty — expected a command to spawn (e.g. \"omg mcp -C /vault\") or an http(s) URL".to_owned(),
99        ));
100    };
101    Ok(EngineSpec::Stdio {
102        command,
103        args: argv.collect(),
104    })
105}
106
107// ---- tool results ----------------------------------------------------------------------
108
109/// One `tools/call` outcome: the JSON the tool's text content held, and
110/// whether the server flagged it (`isError`; the body is then the error
111/// envelope of `spec/surface` §4 — `{ error, message, data?, retriable }`).
112#[derive(Clone, Debug, PartialEq)]
113pub struct ToolResult {
114    pub body: Value,
115    pub is_error: bool,
116}
117
118impl ToolResult {
119    /// The body, or the envelope as an [`Error::Other`] carrying the code
120    /// and message (the reference's `tool <name> failed: <code> — <message>`).
121    pub fn into_result(self, tool: &str) -> Result<Value> {
122        if !self.is_error {
123            return Ok(self.body);
124        }
125        let code = self
126            .body
127            .get("error")
128            .and_then(Value::as_str)
129            .unwrap_or("error");
130        let message = self
131            .body
132            .get("message")
133            .and_then(Value::as_str)
134            .map_or_else(|| clip(&self.body.to_string(), 200), str::to_owned);
135        Err(Error::Other(format!(
136            "tool {tool} failed: {code} — {message}"
137        )))
138    }
139}
140
141fn clip(s: &str, max: usize) -> String {
142    if s.chars().count() <= max {
143        s.to_owned()
144    } else {
145        s.chars().take(max).collect()
146    }
147}
148
149// ---- the client -------------------------------------------------------------------------
150
151enum Transport {
152    Stdio(StdioTransport),
153    #[cfg(feature = "http")]
154    Http(HttpTransport),
155}
156
157/// A connected MCP client: `initialize` done, ready for `tools/call`.
158pub struct McpEngineClient {
159    transport: Transport,
160    next_id: i64,
161    timeout: Duration,
162    protocol_version: String,
163    server_info: Value,
164}
165
166impl std::fmt::Debug for McpEngineClient {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        f.debug_struct("McpEngineClient")
169            .field("protocol_version", &self.protocol_version)
170            .field("server_info", &self.server_info)
171            .finish_non_exhaustive()
172    }
173}
174
175impl McpEngineClient {
176    /// Connect over the transport `spec` selects and run the handshake.
177    pub fn connect(spec: &EngineSpec) -> Result<Self> {
178        Self::connect_with(spec, DEFAULT_TIMEOUT)
179    }
180
181    /// [`Self::connect`] with a per-request timeout.
182    pub fn connect_with(spec: &EngineSpec, timeout: Duration) -> Result<Self> {
183        match spec {
184            EngineSpec::Stdio { command, args } => Self::connect_stdio(command, args, timeout),
185            #[cfg(feature = "http")]
186            EngineSpec::Http { url, headers } => Self::connect_http(url, headers, timeout),
187            #[cfg(not(feature = "http"))]
188            EngineSpec::Http { url, .. } => Err(Error::Other(format!(
189                "cannot reach {url}: this build has no HTTP transport (omgbase-sync feature `http`)"
190            ))),
191        }
192    }
193
194    /// Spawn `command args…` (stderr inherited) and connect over its stdio.
195    pub fn connect_stdio(command: &str, args: &[String], timeout: Duration) -> Result<Self> {
196        let transport = StdioTransport::spawn(command, args)?;
197        Self::handshake(Transport::Stdio(transport), timeout)
198    }
199
200    /// Connect to a Streamable HTTP endpoint; `headers` are sent on every
201    /// request.
202    #[cfg(feature = "http")]
203    pub fn connect_http(
204        url: &str,
205        headers: &[(String, String)],
206        timeout: Duration,
207    ) -> Result<Self> {
208        let transport = HttpTransport::new(url, headers, timeout);
209        Self::handshake(Transport::Http(transport), timeout)
210    }
211
212    fn handshake(transport: Transport, timeout: Duration) -> Result<Self> {
213        let mut client = Self {
214            transport,
215            next_id: 0,
216            timeout,
217            protocol_version: PROTOCOL_VERSION.to_owned(),
218            server_info: Value::Null,
219        };
220        let init = client.request(
221            "initialize",
222            json!({
223                "protocolVersion": PROTOCOL_VERSION,
224                "capabilities": {},
225                "clientInfo": { "name": CLIENT_NAME, "version": env!("CARGO_PKG_VERSION") },
226            }),
227        );
228        let init = match init {
229            Ok(v) => v,
230            Err(e) => {
231                let _ = client.close();
232                return Err(e);
233            }
234        };
235        if let Some(v) = init.get("protocolVersion").and_then(Value::as_str) {
236            client.protocol_version = v.to_owned();
237            #[cfg(feature = "http")]
238            if let Transport::Http(h) = &mut client.transport {
239                h.protocol_version = Some(v.to_owned());
240            }
241        }
242        client.server_info = init.get("serverInfo").cloned().unwrap_or(Value::Null);
243        client.notify("notifications/initialized", Value::Null)?;
244        Ok(client)
245    }
246
247    /// The protocol revision the server answered with.
248    #[must_use]
249    pub fn protocol_version(&self) -> &str {
250        &self.protocol_version
251    }
252
253    /// The server's `serverInfo` (`{ name, version }`).
254    #[must_use]
255    pub fn server_info(&self) -> &Value {
256        &self.server_info
257    }
258
259    /// One JSON-RPC request; the `result`, or the server's error as
260    /// [`Error::Other`].
261    pub fn request(&mut self, method: &str, params: Value) -> Result<Value> {
262        self.next_id += 1;
263        let id = json!(self.next_id);
264        let mut msg = json!({ "jsonrpc": "2.0", "id": id, "method": method });
265        if !params.is_null() {
266            msg["params"] = params;
267        }
268        let reply = match &mut self.transport {
269            Transport::Stdio(t) => t.request(&msg, &id, self.timeout)?,
270            #[cfg(feature = "http")]
271            Transport::Http(t) => t.post(&msg, Some(&id))?.ok_or_else(|| {
272                Error::Other(format!(
273                    "{method}: the server accepted the request without a reply"
274                ))
275            })?,
276        };
277        if let Some(err) = reply.get("error") {
278            let code = err.get("code").and_then(Value::as_i64).unwrap_or(0);
279            let message = err
280                .get("message")
281                .and_then(Value::as_str)
282                .unwrap_or("unknown error");
283            return Err(Error::Other(format!(
284                "{method} failed: {message} (JSON-RPC {code})"
285            )));
286        }
287        reply
288            .get("result")
289            .cloned()
290            .ok_or_else(|| Error::Other(format!("{method}: reply without result")))
291    }
292
293    /// One JSON-RPC notification (no reply).
294    pub fn notify(&mut self, method: &str, params: Value) -> Result<()> {
295        let mut msg = json!({ "jsonrpc": "2.0", "method": method });
296        if !params.is_null() {
297            msg["params"] = params;
298        }
299        match &mut self.transport {
300            Transport::Stdio(t) => t.send(&msg),
301            #[cfg(feature = "http")]
302            Transport::Http(t) => t.post(&msg, None).map(|_| ()),
303        }
304    }
305
306    /// `tools/list`: the catalog as the server describes it.
307    pub fn list_tools(&mut self) -> Result<Vec<Value>> {
308        let res = self.request("tools/list", Value::Null)?;
309        Ok(res
310            .get("tools")
311            .and_then(Value::as_array)
312            .cloned()
313            .unwrap_or_default())
314    }
315
316    /// `tools/call`: the tool's JSON body and its error flag. A reply whose
317    /// text content is not JSON is an error (the reference's
318    /// `tool <name> returned non-JSON`).
319    pub fn call_tool(&mut self, name: &str, args: Value) -> Result<ToolResult> {
320        let res = self.request("tools/call", json!({ "name": name, "arguments": args }))?;
321        let text = res
322            .get("content")
323            .and_then(Value::as_array)
324            .and_then(|items| {
325                items
326                    .iter()
327                    .find(|c| c.get("type").and_then(Value::as_str) == Some("text"))
328            })
329            .and_then(|c| c.get("text"))
330            .and_then(Value::as_str)
331            .unwrap_or("");
332        let body: Value = serde_json::from_str(text).map_err(|_| {
333            Error::Other(format!(
334                "tool {name} returned non-JSON: {}",
335                clip(text, 200)
336            ))
337        })?;
338        Ok(ToolResult {
339            body,
340            is_error: res.get("isError").and_then(Value::as_bool).unwrap_or(false),
341        })
342    }
343
344    /// [`Self::call_tool`] with an error result turned into an error.
345    pub fn call(&mut self, name: &str, args: Value) -> Result<Value> {
346        self.call_tool(name, args)?.into_result(name)
347    }
348
349    /// End the session: stdio — stdin EOF, wait, kill after the grace
350    /// period; HTTP — `DELETE` with the session id. Idempotent.
351    pub fn close(&mut self) -> Result<()> {
352        match &mut self.transport {
353            Transport::Stdio(t) => t.close(),
354            #[cfg(feature = "http")]
355            Transport::Http(t) => {
356                t.delete_session();
357                Ok(())
358            }
359        }
360    }
361}
362
363impl Drop for McpEngineClient {
364    fn drop(&mut self) {
365        let _ = self.close();
366    }
367}
368
369// ---- the engine seam --------------------------------------------------------------------
370
371fn str_field(v: &Value, key: &str) -> String {
372    v.get(key)
373        .and_then(Value::as_str)
374        .unwrap_or_default()
375        .to_owned()
376}
377
378fn opt_str_field(v: &Value, key: &str) -> Option<String> {
379    v.get(key).and_then(Value::as_str).map(str::to_owned)
380}
381
382fn bool_field(v: &Value, key: &str) -> bool {
383    v.get(key).and_then(Value::as_bool).unwrap_or(false)
384}
385
386/// An `observe_many` member as the wire carries it → the store's outcome.
387/// Hashes are not on the wire (`old_hash_hex` is `None`, `new_hash_hex`
388/// empty); the coordinator reads `echo`/`conflicted`/`path` only.
389fn observe_outcome(v: &Value) -> ObserveOutcome {
390    let dispositions: BTreeMap<String, u64> = v
391        .get("dispositions")
392        .and_then(Value::as_array)
393        .map(|a| {
394            a.iter()
395                .filter_map(|d| {
396                    Some((
397                        d.get("kind")?.as_str()?.to_owned(),
398                        d.get("count")?.as_u64()?,
399                    ))
400                })
401                .collect()
402        })
403        .unwrap_or_default();
404    ObserveOutcome {
405        path: str_field(v, "path"),
406        doc_id: str_field(v, "docId"),
407        rev: opt_str_field(v, "rev"),
408        commit_id: opt_str_field(v, "commitId"),
409        converged: bool_field(v, "converged"),
410        echo: bool_field(v, "echo"),
411        conflicted: bool_field(v, "conflicted"),
412        dispositions,
413        old_hash_hex: None,
414        new_hash_hex: String::new(),
415    }
416}
417
418/// A `changes_since` page as the wire carries it (`camelCase`).
419fn changes_page(v: &Value, cursor: i64) -> ChangesPage {
420    let digests = v
421        .get("digests")
422        .and_then(Value::as_array)
423        .map(|a| {
424            a.iter()
425                .map(|d| CommitDigest {
426                    commit: str_field(d, "commit"),
427                    seq: d.get("seq").and_then(Value::as_i64).unwrap_or(0),
428                    ts: str_field(d, "ts"),
429                    origin: str_field(d, "origin"),
430                    actor: opt_str_field(d, "actor"),
431                    summary: str_field(d, "summary"),
432                    revisions: d
433                        .get("revisions")
434                        .and_then(Value::as_array)
435                        .map(|rs| {
436                            rs.iter()
437                                .map(|r| DigestRevision {
438                                    doc: str_field(r, "doc"),
439                                    path: str_field(r, "path"),
440                                    content_hash: str_field(r, "contentHash"),
441                                })
442                                .collect()
443                        })
444                        .unwrap_or_default(),
445                })
446                .collect()
447        })
448        .unwrap_or_default();
449    ChangesPage {
450        digests,
451        cursor: v.get("cursor").and_then(Value::as_i64).unwrap_or(cursor),
452        truncated: bool_field(v, "truncated"),
453        head: v.get("head").and_then(Value::as_i64).unwrap_or(0),
454    }
455}
456
457impl EngineClient for McpEngineClient {
458    fn observe_many(&mut self, files: &[FileBytes]) -> Result<Vec<ObserveOutcome>> {
459        let files: Vec<Value> = files
460            .iter()
461            .map(|f| json!({ "path": f.path, "content": f.content }))
462            .collect();
463        let out = self.call("observe_many", json!({ "files": files }))?;
464        let items = out.as_array().ok_or_else(|| {
465            Error::Other(format!(
466                "observe_many returned {} instead of an array",
467                clip(&out.to_string(), 200)
468            ))
469        })?;
470        Ok(items.iter().map(observe_outcome).collect())
471    }
472
473    fn observe_delete(&mut self, path: &str) -> Result<DeleteOutcome> {
474        let out = self.call("observe_delete", json!({ "path": path }))?;
475        Ok(DeleteOutcome {
476            path: opt_str_field(&out, "path").unwrap_or_else(|| path.to_owned()),
477            doc_id: opt_str_field(&out, "docId"),
478            old_hash_hex: None,
479        })
480    }
481
482    fn changes_since(
483        &mut self,
484        cursor: i64,
485        limit: Option<usize>,
486        origin: Option<&str>,
487    ) -> Result<ChangesPage> {
488        let mut args = Map::new();
489        args.insert("cursor".to_owned(), json!(cursor));
490        if let Some(l) = limit {
491            args.insert("limit".to_owned(), json!(l));
492        }
493        if let Some(o) = origin {
494            args.insert("origin".to_owned(), json!(o));
495        }
496        let out = self.call("changes_since", Value::Object(args))?;
497        Ok(changes_page(&out, cursor))
498    }
499
500    /// `docs_read` by path; a tool error (`doc_missing`, …) reads as absent,
501    /// as the reference treats it. The hash is computed here: the wire
502    /// carries the bytes, not the store's `file_hash`.
503    fn read_doc(&mut self, path: &str) -> Result<Option<DocBytes>> {
504        let res = self.call_tool("docs_read", json!({ "path": path }))?;
505        if res.is_error {
506            return Ok(None);
507        }
508        let Some(content) = res.body.get("content").and_then(Value::as_str) else {
509            return Ok(None);
510        };
511        Ok(Some(DocBytes {
512            content: content.to_owned(),
513            content_hash: hex(&sha256(content.as_bytes())),
514        }))
515    }
516
517    fn close(&mut self) -> Result<()> {
518        McpEngineClient::close(self)
519    }
520}
521
522// ---- stdio ------------------------------------------------------------------------------
523
524/// A spawned server and the line reader over its stdout.
525struct StdioTransport {
526    command: String,
527    child: Option<Child>,
528    stdin: Option<ChildStdin>,
529    lines: Receiver<String>,
530    reader: Option<JoinHandle<()>>,
531}
532
533impl StdioTransport {
534    fn spawn(command: &str, args: &[String]) -> Result<Self> {
535        let spawn_err = |message: String| {
536            Error::Other(format!(
537                "cannot start the MCP server `{}`: {message}",
538                std::iter::once(command)
539                    .chain(args.iter().map(String::as_str))
540                    .collect::<Vec<_>>()
541                    .join(" ")
542            ))
543        };
544        let mut child = Command::new(command)
545            .args(args)
546            .stdin(Stdio::piped())
547            .stdout(Stdio::piped())
548            .stderr(Stdio::inherit())
549            .spawn()
550            .map_err(|e| spawn_err(e.to_string()))?;
551        let stdin = child
552            .stdin
553            .take()
554            .ok_or_else(|| spawn_err("no stdin pipe".to_owned()))?;
555        let stdout = child
556            .stdout
557            .take()
558            .ok_or_else(|| spawn_err("no stdout pipe".to_owned()))?;
559        let (tx, lines) = channel();
560        let reader = std::thread::Builder::new()
561            .name("omgbase-mcp-client".to_owned())
562            .spawn(move || {
563                for line in BufReader::new(stdout).lines() {
564                    let Ok(line) = line else { break };
565                    if line.trim().is_empty() {
566                        continue;
567                    }
568                    if tx.send(line).is_err() {
569                        break;
570                    }
571                }
572            })
573            .map_err(|e| spawn_err(format!("cannot spawn the reader thread: {e}")))?;
574        Ok(Self {
575            command: command.to_owned(),
576            child: Some(child),
577            stdin: Some(stdin),
578            lines,
579            reader: Some(reader),
580        })
581    }
582
583    fn send(&mut self, msg: &Value) -> Result<()> {
584        let stdin = self
585            .stdin
586            .as_mut()
587            .ok_or_else(|| Error::Other(format!("MCP server `{}` is closed", self.command)))?;
588        serde_json::to_writer(&mut *stdin, msg)
589            .map_err(std::io::Error::other)
590            .and_then(|()| stdin.write_all(b"\n"))
591            .and_then(|()| stdin.flush())
592            .map_err(|e| Error::Other(format!("MCP server `{}`: write failed: {e}", self.command)))
593    }
594
595    /// Send and wait for the reply carrying `id`; every other line (a
596    /// notification, a stray reply) is skipped.
597    fn request(&mut self, msg: &Value, id: &Value, timeout: Duration) -> Result<Value> {
598        self.send(msg)?;
599        let method = msg.get("method").and_then(Value::as_str).unwrap_or("?");
600        let deadline = Instant::now() + timeout;
601        loop {
602            let remaining = deadline.saturating_duration_since(Instant::now());
603            let line = self.lines.recv_timeout(remaining).map_err(|e| match e {
604                RecvTimeoutError::Timeout => Error::Other(format!(
605                    "MCP server `{}`: no reply to {method} within {timeout:?}",
606                    self.command
607                )),
608                RecvTimeoutError::Disconnected => Error::Other(format!(
609                    "MCP server `{}` exited while waiting for {method}",
610                    self.command
611                )),
612            })?;
613            let Ok(reply) = serde_json::from_str::<Value>(&line) else {
614                return Err(Error::Other(format!(
615                    "MCP server `{}` wrote non-JSON to stdout: {}",
616                    self.command,
617                    clip(&line, 200)
618                )));
619            };
620            if reply.get("id") == Some(id) {
621                return Ok(reply);
622            }
623        }
624    }
625
626    fn close(&mut self) -> Result<()> {
627        drop(self.stdin.take());
628        if let Some(mut child) = self.child.take() {
629            let deadline = Instant::now() + EXIT_GRACE;
630            loop {
631                match child.try_wait() {
632                    Ok(Some(_)) => break,
633                    Ok(None) if Instant::now() < deadline => {
634                        std::thread::sleep(Duration::from_millis(20));
635                    }
636                    Ok(None) => {
637                        let _ = child.kill();
638                        let _ = child.wait();
639                        break;
640                    }
641                    Err(_) => break,
642                }
643            }
644        }
645        if let Some(r) = self.reader.take() {
646            let _ = r.join();
647        }
648        Ok(())
649    }
650}
651
652// ---- Streamable HTTP --------------------------------------------------------------------
653
654#[cfg(feature = "http")]
655struct HttpTransport {
656    agent: ureq::Agent,
657    url: String,
658    headers: Vec<(String, String)>,
659    session_id: Option<String>,
660    protocol_version: Option<String>,
661}
662
663#[cfg(feature = "http")]
664impl HttpTransport {
665    fn new(url: &str, headers: &[(String, String)], timeout: Duration) -> Self {
666        let config = ureq::Agent::config_builder()
667            .http_status_as_error(false)
668            .timeout_global(Some(timeout))
669            .build();
670        Self {
671            agent: ureq::Agent::new_with_config(config),
672            url: url.to_owned(),
673            headers: headers.to_vec(),
674            session_id: None,
675            protocol_version: None,
676        }
677    }
678
679    fn fail(&self, what: impl std::fmt::Display) -> Error {
680        Error::Other(format!("MCP server {}: {what}", self.url))
681    }
682
683    fn common_headers<B>(&self, mut req: ureq::RequestBuilder<B>) -> ureq::RequestBuilder<B> {
684        for (k, v) in &self.headers {
685            req = req.header(k.as_str(), v.as_str());
686        }
687        if let Some(sid) = &self.session_id {
688            req = req.header("mcp-session-id", sid.as_str());
689        }
690        if let Some(pv) = &self.protocol_version {
691            req = req.header("mcp-protocol-version", pv.as_str());
692        }
693        req
694    }
695
696    /// `POST` one message. With `want_id`, the reply carrying that id from
697    /// a JSON body or an SSE stream; `None` for a notification (or a 202).
698    fn post(&mut self, msg: &Value, want_id: Option<&Value>) -> Result<Option<Value>> {
699        let req = self
700            .agent
701            .post(&self.url)
702            .header("content-type", "application/json")
703            .header("accept", "application/json, text/event-stream");
704        let req = self.common_headers(req);
705        let mut res = req.send(msg.to_string()).map_err(|e| self.fail(e))?;
706        if let Some(sid) = res
707            .headers()
708            .get("mcp-session-id")
709            .and_then(|v| v.to_str().ok())
710        {
711            self.session_id = Some(sid.to_owned());
712        }
713        let status = res.status().as_u16();
714        if status >= 400 {
715            let text = res.body_mut().read_to_string().unwrap_or_default();
716            return Err(self.fail(format!(
717                "HTTP {status} posting to endpoint: {}",
718                clip(text.trim(), 300)
719            )));
720        }
721        let Some(id) = want_id else {
722            return Ok(None);
723        };
724        if status == 202 {
725            return Ok(None);
726        }
727        let media = res
728            .headers()
729            .get("content-type")
730            .and_then(|v| v.to_str().ok())
731            .map(|ct| {
732                ct.split(';')
733                    .next()
734                    .unwrap_or("")
735                    .trim()
736                    .to_ascii_lowercase()
737            })
738            .unwrap_or_default();
739        match media.as_str() {
740            "application/json" => {
741                let text = res.body_mut().read_to_string().map_err(|e| self.fail(e))?;
742                let body: Value = serde_json::from_str(&text)
743                    .map_err(|e| self.fail(format!("non-JSON body: {e}")))?;
744                let found = match body {
745                    Value::Array(items) => items.into_iter().find(|m| m.get("id") == Some(id)),
746                    other if other.get("id") == Some(id) => Some(other),
747                    _ => None,
748                };
749                found
750                    .map(Some)
751                    .ok_or_else(|| self.fail("the JSON body carried no reply to the request"))
752            }
753            "text/event-stream" => {
754                let reader = BufReader::new(res.body_mut().as_reader());
755                let found = sse_find(reader, id).map_err(|e| self.fail(e))?;
756                found
757                    .map(Some)
758                    .ok_or_else(|| self.fail("the SSE stream ended without a reply to the request"))
759            }
760            other => Err(self.fail(format!("unexpected content type: {other:?}"))),
761        }
762    }
763
764    /// `DELETE` the session (best effort; a 405 means the server keeps no
765    /// sessions).
766    fn delete_session(&mut self) {
767        if self.session_id.is_none() {
768            return;
769        }
770        let req = self.agent.delete(&self.url);
771        let req = self.common_headers(req);
772        let _ = req.call();
773        self.session_id = None;
774    }
775}
776
777/// Read SSE events from `reader` until one's `data` is the JSON-RPC message
778/// carrying `id` (returned), or the stream ends (`None`). Events are blank
779/// line separated; `data:` lines of one event join with `\n`; other fields
780/// (`event`, `id`, `retry`) and comments are skipped.
781#[cfg(feature = "http")]
782fn sse_find(reader: impl BufRead, id: &Value) -> std::io::Result<Option<Value>> {
783    let mut data: Vec<String> = Vec::new();
784    let mut lines = reader.lines();
785    loop {
786        let line = match lines.next() {
787            Some(l) => Some(l?),
788            None => None,
789        };
790        let end_of_event = line.as_deref().is_none_or(|l| l.is_empty());
791        if end_of_event {
792            if !data.is_empty() {
793                let payload = data.join("\n");
794                data.clear();
795                if let Ok(msg) = serde_json::from_str::<Value>(&payload) {
796                    let hit = match &msg {
797                        Value::Array(items) => {
798                            items.iter().find(|m| m.get("id") == Some(id)).cloned()
799                        }
800                        m if m.get("id") == Some(id) => Some(msg.clone()),
801                        _ => None,
802                    };
803                    if hit.is_some() {
804                        return Ok(hit);
805                    }
806                }
807            }
808            if line.is_none() {
809                return Ok(None);
810            }
811            continue;
812        }
813        let line = line.unwrap_or_default();
814        if line.starts_with(':') {
815            continue;
816        }
817        let (field, value) = match line.split_once(':') {
818            Some((f, v)) => (f, v.strip_prefix(' ').unwrap_or(v)),
819            None => (line.as_str(), ""),
820        };
821        if field == "data" {
822            data.push(value.to_owned());
823        }
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn engine_specs_follow_the_one_rule() {
833        let h = |k: &str, v: &str| (k.to_owned(), v.to_owned());
834        assert_eq!(
835            parse_engine_spec("  https://host/k/secret/mcp ", &[h("X-A", "1")]).unwrap(),
836            EngineSpec::Http {
837                url: "https://host/k/secret/mcp".into(),
838                headers: vec![h("X-A", "1")],
839            }
840        );
841        assert_eq!(
842            parse_engine_spec("HTTP://host/mcp", &[]).unwrap(),
843            EngineSpec::Http {
844                url: "HTTP://host/mcp".into(),
845                headers: vec![],
846            }
847        );
848        assert_eq!(
849            parse_engine_spec("omg   mcp -C /vault", &[]).unwrap(),
850            EngineSpec::Stdio {
851                command: "omg".into(),
852                args: vec!["mcp".into(), "-C".into(), "/vault".into()],
853            }
854        );
855        assert!(
856            parse_engine_spec("omg mcp", &[h("X", "y")]).is_err(),
857            "headers need a url"
858        );
859        assert!(parse_engine_spec("   ", &[]).is_err(), "an empty spec");
860        assert!(!is_http_url("httpx://nope") && !is_http_url("http:/one-slash"));
861    }
862
863    #[test]
864    fn tool_results_turn_envelopes_into_errors() {
865        let ok = ToolResult {
866            body: json!({ "items": [] }),
867            is_error: false,
868        };
869        assert_eq!(ok.into_result("docs_list").unwrap(), json!({ "items": [] }));
870        let bad = ToolResult {
871            body: json!({ "error": "doc_missing", "message": "no document for a.md", "retriable": false }),
872            is_error: true,
873        };
874        let msg = bad.into_result("docs_read").unwrap_err().to_string();
875        assert_eq!(
876            msg,
877            "tool docs_read failed: doc_missing — no document for a.md"
878        );
879    }
880
881    #[test]
882    fn wire_shapes_map_to_store_outcomes() {
883        let o = observe_outcome(&json!({
884            "docId": "d_0", "path": "a.md", "rev": "r_0", "commitId": "c_0",
885            "converged": true, "echo": false, "conflicted": false,
886            "dispositions": [{ "kind": "same", "count": 2 }, { "kind": "edited", "count": 1 }],
887        }));
888        assert_eq!((o.doc_id.as_str(), o.path.as_str()), ("d_0", "a.md"));
889        assert_eq!(o.dispositions["same"], 2);
890        assert!(o.converged && !o.echo);
891        let echo = observe_outcome(
892            &json!({ "docId": "d_0", "path": "a.md", "rev": null, "commitId": null, "converged": true, "echo": true, "conflicted": false, "dispositions": [] }),
893        );
894        assert!(echo.echo && echo.rev.is_none());
895        let page = changes_page(
896            &json!({
897                "digests": [{ "commit": "c_1", "seq": 1, "ts": "t", "origin": "api", "actor": null, "summary": "s",
898                              "revisions": [{ "doc": "d_0", "path": "a.md", "contentHash": "ab" }] }],
899                "cursor": 1, "truncated": false, "head": 1,
900            }),
901            0,
902        );
903        assert_eq!(page.digests[0].revisions[0].content_hash, "ab");
904        assert_eq!(page.cursor, 1);
905        assert_eq!(
906            changes_page(&json!({}), 7).cursor,
907            7,
908            "an empty page keeps the input cursor"
909        );
910    }
911
912    /// A `sh` MCP server: replies to `initialize` and to `tools/call` by id,
913    /// swallows notifications, writes one stray notification first.
914    fn sh_server() -> String {
915        r##"
916printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"hi"}}'
917while IFS= read -r line; do
918  case "$line" in
919    *'"id":'*) ;;
920    *) continue ;;
921  esac
922  id="${line#*\"id\":}"; id="${id%%,*}"
923  case "$line" in
924    *'"initialize"'*) printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"sh\",\"version\":\"0\"}}}" ;;
925    *'"docs_list"'*) printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"items\\\":[]}\"}]}}" ;;
926    *'"docs_read"'*) printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"{\\\"error\\\":\\\"doc_missing\\\",\\\"message\\\":\\\"nope\\\",\\\"retriable\\\":false}\"}]}}" ;;
927    *'"tools/list"'*) printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"tools\":[{\"name\":\"docs_list\"}]}}" ;;
928    *) printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"error\":{\"code\":-32601,\"message\":\"method not found\"}}" ;;
929  esac
930done
931"##
932        .to_owned()
933    }
934
935    #[test]
936    fn stdio_handshake_calls_and_closes() {
937        let spec = EngineSpec::Stdio {
938            command: "sh".into(),
939            args: vec!["-c".into(), sh_server()],
940        };
941        let mut c = McpEngineClient::connect_with(&spec, Duration::from_secs(10)).unwrap();
942        assert_eq!(c.protocol_version(), "2024-11-05");
943        assert_eq!(c.server_info()["name"], "sh");
944        assert_eq!(c.list_tools().unwrap()[0]["name"], "docs_list");
945        assert_eq!(
946            c.call("docs_list", json!({})).unwrap(),
947            json!({ "items": [] })
948        );
949        let r = c.call_tool("docs_read", json!({ "doc": "x" })).unwrap();
950        assert!(r.is_error);
951        assert_eq!(r.body["error"], "doc_missing");
952        assert!(
953            c.read_doc("x.md").unwrap().is_none(),
954            "a tool error reads as absent"
955        );
956        let err = c
957            .request("resources/list", Value::Null)
958            .unwrap_err()
959            .to_string();
960        assert!(
961            err.contains("method not found") && err.contains("-32601"),
962            "{err}"
963        );
964        c.close().unwrap();
965        c.close().unwrap();
966        assert!(c.call("docs_list", json!({})).is_err(), "closed");
967    }
968
969    #[test]
970    fn a_missing_command_and_a_silent_server_fail_cleanly() {
971        let spec = EngineSpec::Stdio {
972            command: "definitely-not-an-mcp-server-xyz".into(),
973            args: vec![],
974        };
975        let err = McpEngineClient::connect(&spec).unwrap_err().to_string();
976        assert!(err.contains("cannot start the MCP server"), "{err}");
977        // A server that never answers: the bounded wait, then the error.
978        let spec = EngineSpec::Stdio {
979            command: "sh".into(),
980            args: vec!["-c".into(), "cat >/dev/null".into()],
981        };
982        let err = McpEngineClient::connect_with(&spec, Duration::from_millis(300))
983            .unwrap_err()
984            .to_string();
985        assert!(err.contains("no reply to initialize"), "{err}");
986        // A server that exits at once.
987        let spec = EngineSpec::Stdio {
988            command: "sh".into(),
989            args: vec!["-c".into(), "exit 0".into()],
990        };
991        let err = McpEngineClient::connect_with(&spec, Duration::from_secs(5))
992            .unwrap_err()
993            .to_string();
994        assert!(err.contains("exited while waiting"), "{err}");
995    }
996
997    #[cfg(feature = "http")]
998    mod http {
999        use super::*;
1000        use std::io::Read;
1001        use std::net::TcpListener;
1002
1003        /// A one-connection-at-a-time HTTP server that answers `n` requests:
1004        /// `initialize` → JSON with `Mcp-Session-Id: s1`; a notification →
1005        /// 202; `tools/call` → an SSE stream (a comment, an unrelated event,
1006        /// then the reply); `DELETE` → 200. Records every request's headers.
1007        fn serve(n: usize) -> (String, std::thread::JoinHandle<Vec<String>>) {
1008            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1009            let url = format!("http://{}/k/secret/mcp", listener.local_addr().unwrap());
1010            let h = std::thread::spawn(move || {
1011                let mut seen = Vec::new();
1012                for _ in 0..n {
1013                    let (mut s, _) = listener.accept().unwrap();
1014                    let mut buf = Vec::new();
1015                    let mut tmp = [0u8; 4096];
1016                    let (head, body_len) = loop {
1017                        let k = s.read(&mut tmp).unwrap();
1018                        buf.extend_from_slice(&tmp[..k]);
1019                        if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1020                            let head = String::from_utf8_lossy(&buf[..pos]).into_owned();
1021                            let len = head
1022                                .lines()
1023                                .find_map(|l| {
1024                                    let (k, v) = l.split_once(':')?;
1025                                    k.eq_ignore_ascii_case("content-length")
1026                                        .then(|| v.trim().parse::<usize>().ok())?
1027                                })
1028                                .unwrap_or(0);
1029                            buf.drain(..pos + 4);
1030                            break (head, len);
1031                        }
1032                    };
1033                    while buf.len() < body_len {
1034                        let k = s.read(&mut tmp).unwrap();
1035                        buf.extend_from_slice(&tmp[..k]);
1036                    }
1037                    let body = String::from_utf8_lossy(&buf[..body_len]).into_owned();
1038                    seen.push(format!("{head}\n\n{body}"));
1039                    let req: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
1040                    let method = req.get("method").and_then(Value::as_str).unwrap_or("");
1041                    let id = req.get("id").cloned().unwrap_or(Value::Null);
1042                    let reply = if head.starts_with("DELETE") {
1043                        "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1044                            .to_owned()
1045                    } else if method == "initialize" {
1046                        let b = json!({ "jsonrpc": "2.0", "id": id, "result": { "protocolVersion": "2025-03-26", "capabilities": {}, "serverInfo": { "name": "tcp", "version": "1" } } }).to_string();
1047                        format!(
1048                            "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nMcp-Session-Id: s1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}",
1049                            b.len()
1050                        )
1051                    } else if id.is_null() {
1052                        "HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1053                            .to_owned()
1054                    } else {
1055                        let b = json!({ "jsonrpc": "2.0", "id": id, "result": { "content": [{ "type": "text", "text": "{\"items\":[{\"path\":\"a.md\"}]}" }] } }).to_string();
1056                        let sse = format!(
1057                            ": keep-alive\n\nevent: message\ndata: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{{}}}}\n\nid: 7\nevent: message\ndata: {b}\n\n"
1058                        );
1059                        format!(
1060                            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{sse}",
1061                            sse.len()
1062                        )
1063                    };
1064                    s.write_all(reply.as_bytes()).unwrap();
1065                }
1066                seen
1067            });
1068            (url, h)
1069        }
1070
1071        #[test]
1072        fn streamable_http_round_trips_session_headers_json_and_sse() {
1073            let (url, server) = serve(4);
1074            let spec = EngineSpec::Http {
1075                url: url.clone(),
1076                headers: vec![("X-Auth".to_owned(), "t0k".to_owned())],
1077            };
1078            let mut c = McpEngineClient::connect_with(&spec, Duration::from_secs(10)).unwrap();
1079            assert_eq!(c.server_info()["name"], "tcp");
1080            let out = c.call("docs_list", json!({})).unwrap();
1081            assert_eq!(out["items"][0]["path"], "a.md");
1082            c.close().unwrap();
1083            let seen = server.join().unwrap();
1084            assert_eq!(seen.len(), 4, "initialize, initialized, tools/call, DELETE");
1085            let lower: Vec<String> = seen.iter().map(|s| s.to_ascii_lowercase()).collect();
1086            assert!(
1087                lower[0].starts_with("post /k/secret/mcp "),
1088                "the url is used verbatim"
1089            );
1090            assert!(lower[0].contains("accept: application/json, text/event-stream"));
1091            assert!(
1092                lower[0].contains("x-auth: t0k"),
1093                "extra headers on every request"
1094            );
1095            assert!(
1096                !lower[0].contains("mcp-session-id"),
1097                "no session before initialize"
1098            );
1099            assert!(
1100                lower[1].contains("mcp-session-id: s1"),
1101                "the session id rides after"
1102            );
1103            assert!(lower[1].contains("mcp-protocol-version: 2025-03-26"));
1104            assert!(
1105                lower[2].contains("\"method\":\"tools/call\"") && lower[2].contains("x-auth: t0k")
1106            );
1107            assert!(lower[3].starts_with("delete ") && lower[3].contains("mcp-session-id: s1"));
1108        }
1109
1110        #[test]
1111        fn http_errors_are_reported() {
1112            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1113            let url = format!("http://{}/mcp", listener.local_addr().unwrap());
1114            std::thread::spawn(move || {
1115                let (mut s, _) = listener.accept().unwrap();
1116                let mut tmp = [0u8; 4096];
1117                let _ = s.read(&mut tmp);
1118                s.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\nContent-Length: 6\r\nConnection: close\r\n\r\nnot ok").unwrap();
1119            });
1120            let spec = EngineSpec::Http {
1121                url,
1122                headers: vec![],
1123            };
1124            let err = McpEngineClient::connect_with(&spec, Duration::from_secs(5))
1125                .unwrap_err()
1126                .to_string();
1127            assert!(err.contains("HTTP 401") && err.contains("not ok"), "{err}");
1128        }
1129
1130        #[test]
1131        fn sse_parsing_finds_the_reply() {
1132            let stream = ": hello\n\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"n\"}\n\ndata: {\"jsonrpc\":\"2.0\",\ndata: \"id\":3,\"result\":{}}\n\n";
1133            let found = sse_find(stream.as_bytes(), &json!(3)).unwrap().unwrap();
1134            assert_eq!(found["result"], json!({}));
1135            assert!(sse_find(stream.as_bytes(), &json!(4)).unwrap().is_none());
1136            // No trailing blank line: the final event still counts.
1137            let tail = "data: {\"id\":1,\"result\":1}";
1138            assert_eq!(
1139                sse_find(tail.as_bytes(), &json!(1)).unwrap().unwrap()["result"],
1140                1
1141            );
1142        }
1143    }
1144}