use anyhow::{Context, Result};
use mcpmesh_net::errors::{ERR_SERVICE, ERR_UNREACHABLE};
use mcpmesh_net::framing::{FrameReader, Inbound, write_frame};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use crate::client::ensure_daemon;
use crate::ipc::MAX_FRAME_BYTES;
pub fn split_target(target: &str) -> Result<(String, String)> {
let (peer, service) = target
.split_once('/')
.with_context(|| format!("target '{target}' must be <peer>/<service>"))?;
anyhow::ensure!(
!peer.is_empty() && !service.is_empty(),
"target '{target}' must be <peer>/<service>"
);
Ok((peer.to_string(), service.to_string()))
}
fn mcp_servers_entry(peer: &str, service: &str) -> String {
let name = serde_json::to_string(&format!("{peer}-{service}")).expect("a string serializes");
let target = serde_json::to_string(&format!("{peer}/{service}")).expect("a string serializes");
format!(r#"{name}: {{"command": "mcpmesh", "args": ["connect", {target}]}}"#)
}
fn claude_desktop_config_path() -> String {
if cfg!(windows) {
let base = std::env::var("APPDATA").unwrap_or_else(|_| r"%APPDATA%".to_string());
return format!(r"{base}\Claude\claude_desktop_config.json");
}
let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
if cfg!(target_os = "macos") {
format!("{home}/Library/Application Support/Claude/claude_desktop_config.json")
} else {
format!("{home}/.config/Claude/claude_desktop_config.json")
}
}
pub fn client_instruction_lines(peer: &str, services: &[String]) -> Vec<String> {
if services.is_empty() {
return Vec::new();
}
let mut lines = vec![
format!(
"Tools from {peer} run on {peer}'s machine. Treat their output as you would \
anything else {peer} sends you."
),
String::new(),
"To use in Claude Code, run:".to_string(),
];
for s in services {
lines.push(format!(
" claude mcp add {peer}-{s} -- mcpmesh connect {peer}/{s}"
));
}
lines.push(String::new());
lines.push("To use in Claude Desktop, add this under \"mcpServers\" in".to_string());
lines.push(format!(" {}", claude_desktop_config_path()));
lines.push("then quit and restart Claude Desktop:".to_string());
for (i, s) in services.iter().enumerate() {
let comma = if i + 1 < services.len() { "," } else { "" };
lines.push(format!(" {}{comma}", mcp_servers_entry(peer, s)));
}
lines.push(String::new());
lines.push(format!(
"Any other MCP client: add a stdio server with the command `mcpmesh connect {peer}/{}`.",
services[0]
));
lines
}
pub async fn run(peer: String, service: String) -> Result<()> {
let client = ensure_daemon().await?;
let (control_reader, control_writer) = client.open_session(peer.clone(), service).await?;
let ended_on_mesh_error = pump_stdio(
tokio::io::stdin(),
tokio::io::stdout(),
control_reader,
control_writer,
)
.await?;
if ended_on_mesh_error.is_some() && std::io::IsTerminal::is_terminal(&std::io::stdout()) {
eprintln!("{}", unreachable_hint_line(&peer));
}
Ok(())
}
fn unreachable_hint_line(peer: &str) -> String {
format!(
"peer unreachable — is {peer} online? ('mcpmesh status' shows reachability). \
This command is normally run by your MCP client."
)
}
fn mesh_error_code(frame: &Value) -> Option<i64> {
let error = frame.get("error")?;
if error.get("data")?.get("source")?.as_str()? != "mcpmesh" {
return None;
}
let code = error.get("code")?.as_i64()?;
(code == ERR_UNREACHABLE || code == ERR_SERVICE).then_some(code)
}
pub(crate) async fn pump_stdio<SI, SO, CR, CW>(
stdin: SI,
mut stdout: SO,
mut control_reader: FrameReader<CR>,
mut control_writer: CW,
) -> Result<Option<i64>>
where
SI: AsyncRead + Unpin + Send,
SO: AsyncWrite + Unpin + Send,
CR: AsyncRead + Unpin + Send,
CW: AsyncWrite + Unpin + Send,
{
let mut stdin = FrameReader::new(BufReader::new(stdin), MAX_FRAME_BYTES);
let to_control = async {
loop {
match stdin.next().await {
Ok(Some(Inbound::Frame(frame))) => {
if write_frame(&mut control_writer, &frame).await.is_err() {
break; }
}
Ok(Some(Inbound::Violation(_))) => break,
Ok(None) | Err(_) => break, }
}
let _ = control_writer.shutdown().await;
std::future::pending::<()>().await
};
let to_stdout = async {
let mut ended_on: Option<i64> = None;
loop {
match control_reader.next().await {
Ok(Some(Inbound::Frame(frame))) => {
ended_on = mesh_error_code(&frame);
if write_frame(&mut stdout, &frame).await.is_err() {
break; }
}
Ok(Some(Inbound::Violation(_))) => break,
Ok(None) | Err(_) => break, }
}
ended_on
};
let ended_on = tokio::select! {
() = to_control => None, ended = to_stdout => ended,
};
let _ = stdout.shutdown().await;
Ok(ended_on)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use serde_json::{Value, json};
use tokio::io::{duplex, split};
use tokio::time::timeout;
use super::*;
#[test]
fn mcp_servers_entry_parses_back_to_the_exact_wire_shape() {
let doc: Value =
serde_json::from_str(&format!("{{{}}}", mcp_servers_entry("alice", "notes"))).unwrap();
assert_eq!(
doc,
json!({ "alice-notes": { "command": "mcpmesh", "args": ["connect", "alice/notes"] } })
);
}
#[test]
fn mcp_servers_entry_escapes_an_exotic_nickname() {
let doc: Value =
serde_json::from_str(&format!("{{{}}}", mcp_servers_entry(r#"a"b"#, "notes"))).unwrap();
assert_eq!(
doc,
json!({ r#"a"b-notes"#: { "command": "mcpmesh", "args": ["connect", r#"a"b/notes"#] } })
);
}
#[test]
fn client_instruction_lines_name_both_clients_with_copyable_commands() {
let lines = client_instruction_lines("alice", &["notes".to_string()]);
let rendered = lines.join("\n");
assert!(
rendered.contains("claude mcp add alice-notes -- mcpmesh connect alice/notes"),
"the Claude Code command must be spelled out verbatim:\n{rendered}"
);
assert!(
rendered.contains("claude_desktop_config.json"),
"the Claude Desktop config path must be named:\n{rendered}"
);
assert!(
rendered.contains(
r#""alice-notes": {"command": "mcpmesh", "args": ["connect", "alice/notes"]}"#
),
"the Claude Desktop entry must be copy-pasteable:\n{rendered}"
);
assert!(
rendered.contains("mcpServers") && rendered.to_lowercase().contains("restart"),
"the Desktop instructions must say where it goes and to restart:\n{rendered}"
);
assert!(
rendered.contains("run on alice's machine"),
"the trust-boundary line must survive:\n{rendered}"
);
}
#[test]
fn client_instruction_lines_render_every_granted_service() {
let services = vec!["notes".to_string(), "kb".to_string()];
let rendered = client_instruction_lines("alice", &services).join("\n");
assert!(rendered.contains("claude mcp add alice-notes -- mcpmesh connect alice/notes"));
assert!(rendered.contains("claude mcp add alice-kb -- mcpmesh connect alice/kb"));
assert!(
rendered.contains(r#""connect", "alice/notes"]},"#),
"all but the last Desktop entry are comma-terminated:\n{rendered}"
);
assert!(
rendered.contains(r#""connect", "alice/kb"]}"#)
&& !rendered.contains(r#""connect", "alice/kb"]},"#),
"the last Desktop entry has no trailing comma:\n{rendered}"
);
}
#[test]
fn client_instruction_lines_are_empty_without_services() {
assert!(client_instruction_lines("alice", &[]).is_empty());
}
#[test]
fn split_target_accepts_an_eid_principal() {
let hex = "a".repeat(64);
let (peer, service) = split_target(&format!("eid:{hex}/notes")).unwrap();
assert_eq!(peer, format!("eid:{hex}"));
assert_eq!(service, "notes");
}
#[test]
fn split_target_requires_both_halves() {
assert_eq!(
split_target("alice/notes").unwrap(),
("alice".into(), "notes".into())
);
assert!(split_target("alice").is_err());
assert!(split_target("alice/").is_err());
assert!(split_target("/notes").is_err());
}
#[tokio::test]
async fn pump_relays_both_directions_verbatim() {
timeout(Duration::from_secs(10), async {
let (mut a_in, b_in) = duplex(64 * 1024);
let (a_out, b_out) = duplex(64 * 1024);
let (pc, dc) = duplex(64 * 1024);
let (pc_r, pc_w) = split(pc);
let (dc_r, dc_w) = split(dc);
let pump = tokio::spawn(pump_stdio(
b_in,
b_out,
FrameReader::new(pc_r, MAX_FRAME_BYTES),
pc_w,
));
let mut daemon_reader = FrameReader::new(dc_r, MAX_FRAME_BYTES);
let mut dc_w = dc_w;
let mut a_out_reader = FrameReader::new(a_out, MAX_FRAME_BYTES);
let init = json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}});
write_frame(&mut a_in, &init).await.unwrap();
match daemon_reader.next().await.unwrap().unwrap() {
Inbound::Frame(f) => assert_eq!(f, init, "stdin frame must reach control verbatim"),
other => panic!("expected a frame, got {other:?}"),
}
let err = json!({"jsonrpc":"2.0","id":null,"error":{"code":-32055,"message":"peer unreachable","data":{"source":"mcpmesh"}}});
write_frame(&mut dc_w, &err).await.unwrap();
match a_out_reader.next().await.unwrap().unwrap() {
Inbound::Frame(f) => assert_eq!(f, err, "control frame must reach stdout verbatim"),
other => panic!("expected a frame, got {other:?}"),
}
drop(a_in);
assert!(
daemon_reader.next().await.unwrap().is_none(),
"stdin close must propagate to the daemon as a half-close"
);
drop(daemon_reader);
drop(dc_w);
assert_eq!(pump.await.unwrap().unwrap(), Some(-32055));
})
.await
.expect("pump test timed out");
}
#[tokio::test]
async fn buffered_error_frame_survives_a_dead_control_writer() {
timeout(Duration::from_secs(10), async {
for _ in 0..50 {
let (mut a_in, b_in) = duplex(64 * 1024);
let (a_out, b_out) = duplex(64 * 1024);
let (pc, dc) = duplex(64 * 1024);
let (pc_r, pc_w) = split(pc);
let (dc_r, mut dc_w) = split(dc);
let err = json!({"jsonrpc":"2.0","id":null,"error":{"code":-32055,"message":"peer unreachable","data":{"source":"mcpmesh"}}});
write_frame(&mut dc_w, &err).await.unwrap();
drop(dc_w);
drop(dc_r);
let init = json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}});
write_frame(&mut a_in, &init).await.unwrap();
let pump = tokio::spawn(pump_stdio(
b_in,
b_out,
FrameReader::new(pc_r, MAX_FRAME_BYTES),
pc_w,
));
let mut a_out_reader = FrameReader::new(a_out, MAX_FRAME_BYTES);
match a_out_reader.next().await.unwrap() {
Some(Inbound::Frame(f)) => assert_eq!(
f["error"]["code"], -32055,
"the buffered -32055 must reach stdout: {f}"
),
other => panic!("the -32055 must reach stdout, got {other:?}"),
}
drop(a_in);
assert_eq!(pump.await.unwrap().unwrap(), Some(-32055));
}
})
.await
.expect("drain test timed out");
}
#[tokio::test]
async fn clean_session_end_reports_no_mesh_error() {
timeout(Duration::from_secs(10), async {
let (a_in, b_in) = duplex(64 * 1024);
let (a_out, b_out) = duplex(64 * 1024);
let (pc, dc) = duplex(64 * 1024);
let (pc_r, pc_w) = split(pc);
let (dc_r, mut dc_w) = split(dc);
let resp = json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}});
write_frame(&mut dc_w, &resp).await.unwrap();
drop(dc_w);
drop(dc_r);
let pump = tokio::spawn(pump_stdio(
b_in,
b_out,
FrameReader::new(pc_r, MAX_FRAME_BYTES),
pc_w,
));
let mut a_out_reader = FrameReader::new(a_out, MAX_FRAME_BYTES);
match a_out_reader.next().await.unwrap().unwrap() {
Inbound::Frame(f) => assert_eq!(f, resp),
other => panic!("expected the response frame, got {other:?}"),
}
drop(a_in);
assert_eq!(pump.await.unwrap().unwrap(), None);
})
.await
.expect("clean-end test timed out");
}
#[test]
fn mesh_error_code_matches_only_the_synthesized_refusal_pair() {
let unreachable = json!({"jsonrpc":"2.0","id":null,"error":{"code":-32055,
"message":"peer unreachable","data":{"source":"mcpmesh"}}});
assert_eq!(mesh_error_code(&unreachable), Some(-32055));
let refused = json!({"jsonrpc":"2.0","id":null,"error":{"code":-32054,
"message":"unknown or unauthorized service","data":{"source":"mcpmesh"}}});
assert_eq!(mesh_error_code(&refused), Some(-32054));
let backend = json!({"jsonrpc":"2.0","id":1,"error":{"code":-32055,"message":"x"}});
assert_eq!(mesh_error_code(&backend), None);
let limited = json!({"jsonrpc":"2.0","id":1,"error":{"code":-32053,
"message":"rate limited","data":{"source":"mcpmesh"}}});
assert_eq!(mesh_error_code(&limited), None);
assert_eq!(
mesh_error_code(&json!({"jsonrpc":"2.0","id":1,"result":{}})),
None
);
}
#[test]
fn unreachable_hint_line_names_the_peer_and_the_status_command_only() {
let line = unreachable_hint_line("alice");
assert!(
line.contains("is alice online?") && line.contains("'mcpmesh status'"),
"the hint names the peer and the exact next command: {line}"
);
assert!(
line.contains("normally run by your MCP client"),
"the hint explains who normally runs connect: {line}"
);
for term in ["ALPN", "endpoint", "iroh", "dial", "-32055"] {
assert!(!line.contains(term), "hint leaked '{term}': {line}");
}
}
}