use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use mcpmesh::enrollcmd::{load_device_key, split_csv, with_daemon};
use mcpmesh::render::{self, SERVE_EXAMPLE};
use mcpmesh::{config, daemon, doctor, enrollcmd, proxy, util};
use mcpmesh_local_api::{BackendSpec, PeerAddParams};
use mcpmesh_trust::paths;
fn serve_after_help() -> String {
format!(
"Example — share a folder of notes (needs npx; no MCP server of your own required):\n \
{SERVE_EXAMPLE}\n\nThen `mcpmesh invite notes` mints an invite to send whoever you're \
sharing with."
)
}
#[derive(Parser)]
#[command(name = "mcpmesh", version)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
Status,
Doctor,
#[command(after_help = serve_after_help())]
Serve {
name: String,
#[arg(long)]
allow: Option<String>,
#[arg(last = true, required = true)]
cmd: Vec<String>,
},
Connect {
target: String,
},
Invite {
services: Vec<String>,
},
Pair {
invite: Option<String>,
#[arg(long, value_name = "petname")]
remove: Option<String>,
},
Use {
target: String,
},
Join {
org_invite: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
user_id: Option<String>,
#[arg(long, default_value = "laptop")]
label: String,
},
Org {
#[command(subcommand)]
command: OrgCmd,
},
Devices {
#[command(subcommand)]
command: DevicesCmd,
},
Internal {
#[command(subcommand)]
command: Internal,
},
}
#[derive(Subcommand)]
enum OrgCmd {
Create {
name: String,
#[arg(long)]
expires: Option<String>,
#[arg(long)]
roster_url: Option<String>,
},
Approve {
join_code: String,
#[arg(long)]
groups: String,
#[arg(long)]
user_id: Option<String>,
},
Revoke {
target: String,
#[arg(long)]
user_key: bool,
},
}
#[derive(Subcommand)]
enum DevicesCmd {
Code {
#[arg(long, default_value = "desktop")]
label: String,
},
Add {
device_code: String,
},
}
#[derive(Subcommand)]
enum Internal {
Daemon,
Id,
Peer {
#[command(subcommand)]
command: PeerCmd,
},
Roster {
#[command(subcommand)]
command: RosterCmd,
},
Blob {
#[command(subcommand)]
command: BlobCmd,
},
Audit {
#[command(subcommand)]
command: AuditCmd,
},
Watch,
}
#[derive(Subcommand)]
enum BlobCmd {
Publish {
scope: String,
file: PathBuf,
},
Grant { scope: String, principal: String },
List,
Fetch {
ticket: String,
dest: PathBuf,
},
}
#[derive(clap::Subcommand)]
enum AuditCmd {
Tail {
#[arg(long, default_value_t = 20)]
lines: usize,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
peer: Option<String>,
},
List,
Prune {
#[arg(long, value_name = "YYYY-MM")]
before: String,
},
}
#[derive(Subcommand)]
enum RosterCmd {
Install {
file: PathBuf,
#[arg(long)]
org_root_pk: Option<String>,
},
}
#[derive(Subcommand)]
enum PeerCmd {
Add {
petname: String,
endpoint_id: String,
#[arg(long)]
allow: Option<String>,
},
}
fn main() -> std::process::ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(err) => {
for line in render::error_lines(&err) {
eprintln!("{line}");
}
std::process::ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> anyhow::Result<()> {
match cli.cmd {
Some(Cmd::Internal {
command: Internal::Daemon,
}) => daemon::run(),
Some(Cmd::Internal {
command: Internal::Id,
}) => run_internal_id(),
Some(Cmd::Serve { name, allow, cmd }) => run_serve(name, allow, cmd),
Some(Cmd::Connect { target }) => run_connect(target),
Some(Cmd::Invite { services }) => run_invite(services),
Some(Cmd::Pair { invite, remove }) => run_pair(invite, remove),
Some(Cmd::Use { target }) => run_use(target),
Some(Cmd::Join {
org_invite,
name,
user_id,
label,
}) => enrollcmd::run_join(org_invite, name, user_id, label),
Some(Cmd::Org {
command:
OrgCmd::Create {
name,
expires,
roster_url,
},
}) => enrollcmd::run_org_create(name, expires, roster_url),
Some(Cmd::Org {
command:
OrgCmd::Approve {
join_code,
groups,
user_id,
},
}) => enrollcmd::run_org_approve(join_code, groups, user_id),
Some(Cmd::Org {
command: OrgCmd::Revoke { target, user_key },
}) => enrollcmd::run_org_revoke(target, user_key),
Some(Cmd::Devices {
command: DevicesCmd::Code { label },
}) => enrollcmd::run_devices_code(label),
Some(Cmd::Devices {
command: DevicesCmd::Add { device_code },
}) => enrollcmd::run_devices_add(device_code),
Some(Cmd::Internal {
command:
Internal::Peer {
command:
PeerCmd::Add {
petname,
endpoint_id,
allow,
},
},
}) => run_peer_add(petname, endpoint_id, allow),
Some(Cmd::Internal {
command:
Internal::Roster {
command: RosterCmd::Install { file, org_root_pk },
},
}) => run_roster_install(file, org_root_pk),
Some(Cmd::Internal {
command: Internal::Blob { command },
}) => run_internal_blob(command),
Some(Cmd::Internal {
command: Internal::Audit { command },
}) => run_internal_audit(command),
Some(Cmd::Internal {
command: Internal::Watch,
}) => run_watch(),
Some(Cmd::Doctor) => doctor::run_doctor(),
Some(Cmd::Status) | None => run_status(),
}
}
fn run_serve(name: String, allow: Option<String>, cmd: Vec<String>) -> anyhow::Result<()> {
let allow = split_csv(allow);
with_daemon(async move |mut client| {
client
.register_service(&name, BackendSpec::Run { cmd }, allow)
.await?;
println!("serving '{name}'");
println!(
"Next: run `mcpmesh invite {name}` to mint a one-time invite, and send it to the \
person you want to share it with."
);
Ok(())
})
}
fn run_connect(target: String) -> anyhow::Result<()> {
let (peer, service) = proxy::split_target(&target)?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(proxy::run(peer, service))
}
fn run_invite(services: Vec<String>) -> anyhow::Result<()> {
if services.is_empty() {
anyhow::bail!("specify at least one service to grant (e.g. `mcpmesh invite notes`)");
}
with_daemon(async move |mut client| {
let invite = client.invite(services.clone()).await?;
for line in render::invite_lines(&invite, &services, util::epoch_now_u64()) {
println!("{line}");
}
Ok(())
})
}
fn run_pair(invite: Option<String>, remove: Option<String>) -> anyhow::Result<()> {
match (invite, remove) {
(Some(_), Some(_)) => {
anyhow::bail!("provide an invite to redeem OR --remove <petname>, not both")
}
(None, None) => {
anyhow::bail!("provide an invite to redeem, or --remove <petname> to unpair")
}
(Some(invite_line), None) => with_daemon(async move |mut client| {
let paired = client.pair(&invite_line).await?;
for line in render::pair_lines(&paired) {
println!("{line}");
}
Ok(())
}),
(None, Some(petname)) => with_daemon(async move |mut client| {
client.peer_remove(&petname).await?;
println!("Unpaired {petname}.");
Ok(())
}),
}
}
fn run_use(target: String) -> anyhow::Result<()> {
let (peer, service) = proxy::split_target(&target)?;
with_daemon(async move |mut client| {
let status = client.status().await?;
if let Some(message) = render::use_target_error(&peer, &service, &status.peers) {
anyhow::bail!("{message}");
}
for line in proxy::client_instruction_lines(&peer, &[service]) {
println!("{line}");
}
Ok(())
})
}
fn run_peer_add(petname: String, endpoint_id: String, allow: Option<String>) -> anyhow::Result<()> {
let allow = split_csv(allow);
with_daemon(async move |mut client| {
client
.request(mcpmesh::Request::PeerAdd(PeerAddParams {
petname: petname.clone(),
endpoint_id,
allow,
}))
.await?;
println!("added peer '{petname}'");
Ok(())
})
}
fn run_roster_install(file: PathBuf, org_root_pk: Option<String>) -> anyhow::Result<()> {
let path = file.to_string_lossy().into_owned();
with_daemon(async move |mut client| {
let installed = client.roster_install(&path, org_root_pk).await?;
println!("{}", render::roster_install_line(&installed));
Ok(())
})
}
fn run_internal_blob(command: BlobCmd) -> anyhow::Result<()> {
with_daemon(async move |mut client| {
match command {
BlobCmd::Publish { scope, file } => {
let path = file.to_string_lossy().into_owned();
let r = client.blob_publish(&scope, &path).await?;
println!("Published (hash {}).", r.hash);
println!("{}", r.ticket);
}
BlobCmd::Grant { scope, principal } => {
client.blob_grant(&scope, &principal).await?;
println!("Granted scope '{scope}' to '{principal}'.");
}
BlobCmd::List => {
let r = client.blob_list().await?;
for s in r.scopes {
println!(
"{}: {} blob(s), granted to [{}]",
s.name,
s.hashes.len(),
s.grants.join(", ")
);
}
}
BlobCmd::Fetch { ticket, dest } => {
let dest_path = dest.to_string_lossy().into_owned();
let r = client.blob_fetch(&ticket, &dest_path).await?;
println!(
"Fetched {} bytes (hash {}) → {}",
r.bytes_len,
r.hash,
dest.display()
);
}
}
Ok(())
})
}
fn run_internal_audit(command: AuditCmd) -> anyhow::Result<()> {
use mcpmesh::audit;
let dir = paths::default_audit_dir()?;
match command {
AuditCmd::Tail { lines, kind, peer } => {
let kind_filter = match kind.as_deref() {
Some(s) => {
Some(audit::parse_kind(s).with_context(|| format!("unknown --kind '{s}'"))?)
}
None => None,
};
let all = audit::read_all_records(&dir)?;
let filtered = audit::filter_records(&all, kind_filter, peer.as_deref());
let start = filtered.len().saturating_sub(lines);
for rec in &filtered[start..] {
println!("{}", serde_json::to_string(rec)?);
}
}
AuditCmd::List => {
for (month, _, size) in audit::list_month_files(&dir)? {
println!("{month} {size} bytes");
}
}
AuditCmd::Prune { before } => {
let deleted = audit::prune_before(&dir, &before)?;
if deleted.is_empty() {
println!("Nothing to prune before {before}.");
} else {
println!("Pruned {} month(s): {}.", deleted.len(), deleted.join(", "));
}
}
}
Ok(())
}
fn run_status() -> anyhow::Result<()> {
let fingerprint = load_device_key()?.fingerprint();
let has_roster_url = paths::default_config_path()
.ok()
.and_then(|p| config::Config::load(&p).ok())
.map(|c| c.roster.url.is_some())
.unwrap_or(false);
with_daemon(async move |mut client| {
let hello = client.hello().clone();
let status = client.status().await?;
render::render_status(&fingerprint, &hello, &status, has_roster_url);
Ok(())
})
}
fn run_watch() -> anyhow::Result<()> {
with_daemon(async move |client| {
let mut stream = client.subscribe().await?;
println!("watching the mesh — Ctrl-C to stop");
while let Some(frame) = stream.next().await? {
println!("{}", render::render_frame(&frame));
}
Ok(())
})
}
fn run_internal_id() -> anyhow::Result<()> {
let key = load_device_key()?;
let endpoint_id = iroh::SecretKey::from_bytes(&key.secret_bytes()).public();
println!("{endpoint_id}");
Ok(())
}
#[cfg(test)]
mod tests {
use mcpmesh::roster;
#[test]
fn org_invite_carries_and_round_trips_the_roster_url() {
let url = "https://intranet.acme.com/roster.json";
let code = roster::enroll::OrgInviteCode {
org_id: "acme".into(),
org_root_pk: "b64u:AAAA".into(),
roster_url: Some(url.to_string()),
};
let decoded = roster::enroll::OrgInviteCode::decode(&code.encode()).unwrap();
assert_eq!(decoded.roster_url.as_deref(), Some(url));
assert_eq!(decoded.org_id, "acme");
let bare = roster::enroll::OrgInviteCode {
org_id: "acme".into(),
org_root_pk: "b64u:AAAA".into(),
roster_url: None,
};
assert!(
roster::enroll::OrgInviteCode::decode(&bare.encode())
.unwrap()
.roster_url
.is_none()
);
}
}