1use 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
40pub const PROTOCOL_VERSION: &str = "2025-03-26";
44
45pub const CLIENT_NAME: &str = "omgbase-sync";
47
48pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
51
52const EXIT_GRACE: Duration = Duration::from_secs(5);
55
56#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum EngineSpec {
61 Http {
64 url: String,
65 headers: Vec<(String, String)>,
66 },
67 Stdio { command: String, args: Vec<String> },
69}
70
71#[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
78pub 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#[derive(Clone, Debug, PartialEq)]
113pub struct ToolResult {
114 pub body: Value,
115 pub is_error: bool,
116}
117
118impl ToolResult {
119 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
149enum Transport {
152 Stdio(StdioTransport),
153 #[cfg(feature = "http")]
154 Http(HttpTransport),
155}
156
157pub 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 pub fn connect(spec: &EngineSpec) -> Result<Self> {
178 Self::connect_with(spec, DEFAULT_TIMEOUT)
179 }
180
181 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 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 #[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 #[must_use]
249 pub fn protocol_version(&self) -> &str {
250 &self.protocol_version
251 }
252
253 #[must_use]
255 pub fn server_info(&self) -> &Value {
256 &self.server_info
257 }
258
259 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 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 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 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 pub fn call(&mut self, name: &str, args: Value) -> Result<Value> {
346 self.call_tool(name, args)?.into_result(name)
347 }
348
349 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
369fn 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
386fn 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
418fn 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 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
522struct 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 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#[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 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 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#[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 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 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 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 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 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}