use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use rmcp::{transport::stdio, ServiceExt};
use tracing_subscriber::{fmt, EnvFilter};
mod safety;
mod tools;
mod trace_reader;
mod workspace;
#[derive(Parser, Debug)]
#[command(
name = "solomd-mcp",
version,
about = "MCP server for one or more SoloMD Markdown vaults",
long_about = "Model Context Protocol server that exposes one *or more* SoloMD Markdown notes \
folders as a set of tools (list_notes, read_note, search, get_backlinks, \
list_tags, get_outline, autogit_log/diff/rollback, sync_status, share_url, \
write_note, append_to_note) over JSON-RPC stdio.\n\n\
Pass --workspace once per vault. Each value is either:\n \
/abs/path (alias defaults to the path's last component)\n \
alias=/abs/path (explicit alias)\n\n\
The first workspace is the default — tool calls without an explicit \
`workspace` argument resolve to it, so existing single-workspace clients \
keep working unchanged.\n\n\
Read-only by default. Pass --allow-write to enable write_note, \
append_to_note, and autogit_rollback."
)]
struct Cli {
#[arg(long, value_name = "ALIAS=DIR | DIR", required = true, num_args = 1)]
workspace: Vec<String>,
#[arg(long, default_value_t = false)]
allow_write: bool,
#[arg(long, short = 'v', default_value_t = false)]
verbose: bool,
#[arg(long, value_enum, default_value_t = Transport::Stdio)]
transport: Transport,
#[arg(long, default_value = "127.0.0.1:8765")]
bind: String,
#[arg(long, env = "SOLOMD_MCP_TOKEN")]
auth_token: Option<String>,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum Transport {
Stdio,
Http,
}
pub(crate) fn parse_workspace_arg(raw: &str) -> Result<(String, PathBuf), String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("--workspace value is empty".to_string());
}
let (alias_opt, path_str) = match trimmed.find('=') {
Some(idx) => {
let (left, right) = trimmed.split_at(idx);
let right = &right[1..]; if left.contains('/')
|| left.contains('\\')
|| left.starts_with('.')
|| left.starts_with('~')
{
(None, trimmed)
} else if left.is_empty() {
return Err(format!("--workspace alias is empty in: {raw}"));
} else {
(Some(left.to_string()), right)
}
}
None => (None, trimmed),
};
if path_str.is_empty() {
return Err(format!("--workspace path is empty in: {raw}"));
}
let path = PathBuf::from(path_str);
let canon = path
.canonicalize()
.map_err(|e| format!("workspace not accessible: {} ({e})", path.display()))?;
if !canon.is_dir() {
return Err(format!("workspace is not a directory: {}", canon.display()));
}
let alias = match alias_opt {
Some(a) => a,
None => canon
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("could not derive alias from path: {}", canon.display()))?,
};
if alias.is_empty() {
return Err(format!("derived alias is empty for: {raw}"));
}
if alias.contains('/') || alias.contains('\\') {
return Err(format!("alias must not contain path separators: {alias}"));
}
Ok((alias, canon))
}
pub(crate) fn build_workspace_list(raws: &[String]) -> Result<Vec<(String, PathBuf)>, String> {
if raws.is_empty() {
return Err("at least one --workspace is required".to_string());
}
let mut seen_aliases: HashSet<String> = HashSet::new();
let mut out: Vec<(String, PathBuf)> = Vec::with_capacity(raws.len());
for raw in raws {
let (alias, path) = parse_workspace_arg(raw)?;
if !seen_aliases.insert(alias.clone()) {
return Err(format!(
"duplicate workspace alias: {alias} (use `<alias>=<path>` to disambiguate)"
));
}
out.push((alias, path));
}
Ok(out)
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let default_level = if cli.verbose { "debug" } else { "info" };
let filter =
EnvFilter::try_from_env("SOLOMD_MCP_LOG").unwrap_or_else(|_| EnvFilter::new(default_level));
fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.with_ansi(false)
.init();
let workspaces = build_workspace_list(&cli.workspace).map_err(|e| anyhow::anyhow!(e))?;
for (alias, path) in &workspaces {
tracing::info!(
alias = %alias,
workspace = %path.display(),
"registered workspace"
);
}
tracing::info!(
count = workspaces.len(),
default = %workspaces[0].0,
allow_write = cli.allow_write,
version = env!("CARGO_PKG_VERSION"),
"solomd-mcp starting"
);
let server = tools::SoloMdServer::new(workspaces, cli.allow_write);
match cli.transport {
Transport::Stdio => run_stdio(server).await,
Transport::Http => run_http(server, &cli.bind, cli.auth_token).await,
}
}
async fn run_stdio(server: tools::SoloMdServer) -> Result<()> {
let service = server
.serve(stdio())
.await
.context("failed to start MCP service")?;
let quit_reason = service
.waiting()
.await
.context("MCP service exited with error")?;
tracing::info!(?quit_reason, "solomd-mcp shutting down");
Ok(())
}
async fn run_http(
server: tools::SoloMdServer,
bind: &str,
auth_token: Option<String>,
) -> Result<()> {
use axum::{
extract::Request,
http::StatusCode,
middleware::{self, Next},
response::Response,
Router,
};
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager,
tower::{StreamableHttpServerConfig, StreamableHttpService},
};
let server_factory = Arc::new(server);
let svc = StreamableHttpService::new(
{
let server_factory = server_factory.clone();
move || Ok::<_, std::io::Error>((*server_factory).clone())
},
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let mut router = Router::new().route_service("/mcp", svc);
if let Some(token) = auth_token.clone() {
let expected = format!("Bearer {token}");
let layer = middleware::from_fn(move |req: Request, next: Next| {
let expected = expected.clone();
async move {
let ok = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.map(|h| h == expected)
.unwrap_or(false);
if ok {
Ok::<Response, StatusCode>(next.run(req).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
});
router = router.layer(layer);
}
tracing::info!(
bind = %bind,
auth = if auth_token.is_some() { "bearer" } else { "none" },
"solomd-mcp HTTP transport listening — POST /mcp speaks Streamable HTTP"
);
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("bind {bind} failed"))?;
axum::serve(listener, router)
.await
.context("HTTP server exited with error")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn fresh_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("solomd-mcp-cli-{label}-{nanos}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parse_bare_path_uses_basename_as_alias() {
let dir = fresh_dir("basename");
let arg = dir.to_string_lossy().to_string();
let (alias, path) = parse_workspace_arg(&arg).unwrap();
assert_eq!(alias, dir.file_name().unwrap().to_string_lossy());
assert_eq!(path.canonicalize().unwrap(), dir.canonicalize().unwrap());
}
#[test]
fn parse_alias_equals_path_uses_explicit_alias() {
let dir = fresh_dir("explicit");
let arg = format!("notes={}", dir.display());
let (alias, path) = parse_workspace_arg(&arg).unwrap();
assert_eq!(alias, "notes");
assert_eq!(path.canonicalize().unwrap(), dir.canonicalize().unwrap());
}
#[test]
fn parse_rejects_missing_directory() {
let bogus = format!("alias=/definitely/does/not/exist/{}", std::process::id());
assert!(parse_workspace_arg(&bogus).is_err());
}
#[test]
fn parse_rejects_empty_alias() {
let dir = fresh_dir("empty-alias");
let arg = format!("={}", dir.display());
assert!(parse_workspace_arg(&arg).is_err());
}
#[test]
fn parse_treats_path_with_equals_as_path() {
let arg = "./relative=ish";
let err = parse_workspace_arg(arg).unwrap_err();
assert!(err.contains("not accessible"), "unexpected err: {err}");
}
#[test]
fn build_workspace_list_rejects_duplicate_alias() {
let a = fresh_dir("dup-a");
let b = fresh_dir("dup-b");
let raws = vec![
format!("notes={}", a.display()),
format!("notes={}", b.display()),
];
let err = build_workspace_list(&raws).unwrap_err();
assert!(err.contains("duplicate workspace alias"), "got: {err}");
}
#[test]
fn build_workspace_list_preserves_order() {
let a = fresh_dir("order-a");
let b = fresh_dir("order-b");
let raws = vec![
format!("first={}", a.display()),
format!("second={}", b.display()),
];
let list = build_workspace_list(&raws).unwrap();
assert_eq!(list[0].0, "first");
assert_eq!(list[1].0, "second");
}
#[test]
fn build_workspace_list_rejects_empty_list() {
let raws: Vec<String> = vec![];
assert!(build_workspace_list(&raws).is_err());
}
}