mod cli;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use lds_core::config::Config;
use lds_core::{LdsState, Session, SessionConfig, check_binaries};
use lds_gh::GhModule;
use lds_git::{GitModule, LogFilters, OtherStagedMode, ResetMode};
use lds_journal::JournalModule;
use lds_outline::OutlineModule;
use lds_publicity::{Platform, PublicityModule};
use lds_recipe::RecipeModule;
use lds_router::{ExportRegistry, McpRouter, RouteConfig};
use lds_sandbox::fs::SandboxFs;
use lds_sandbox::python::SandboxPython;
use rmcp::RoleServer;
use rmcp::handler::server::tool::ToolCallContext;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
Annotated, CallToolRequestParams, CallToolResult, Content, ListResourceTemplatesResult,
ListResourcesResult, ListToolsResult, PaginatedRequestParams, RawResource, RawResourceTemplate,
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, ResourceTemplate,
ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
use schemars::JsonSchema;
use serde::Deserialize;
use tokio::sync::RwLock;
use tokio::task::spawn_blocking;
#[derive(Clone)]
struct LdsServer {
state: Arc<RwLock<Inner>>,
}
struct Inner {
lds: LdsState,
git: Option<GitModule>,
gh: Option<GhModule>,
publicity: Option<PublicityModule>,
recipe: Option<RecipeModule>,
sandbox_fs: Option<SandboxFs>,
sandbox_python: Option<SandboxPython>,
journal: Option<JournalModule>,
outline: Option<OutlineModule>,
router: Option<McpRouter>,
export_registry: Option<ExportRegistry>,
startup_cwd: Option<PathBuf>,
startup_global_dirs: Arc<Vec<PathBuf>>,
}
fn resolve_startup_global_dirs(cfg: Config, env_var: Option<OsString>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(path) = cfg.paths.global_justfile {
let expanded = match lds_core::config::tilde_expand(&path.to_string_lossy()) {
Ok(p) => p,
Err(e) => {
tracing::warn!("failed to expand global_justfile path: {e}");
path
}
};
dirs.push(expanded);
}
let config_dirs_count = cfg.recipes.dirs.len();
dirs.extend(cfg.recipes.dirs);
let env_dirs: Vec<PathBuf> = env_var
.map(|v| std::env::split_paths(&v).collect())
.unwrap_or_default();
let env_dirs_count = env_dirs.len();
dirs.extend(env_dirs);
tracing::info!(
config_dirs_count,
env_dirs_count,
"global recipe dirs resolved"
);
dirs
}
impl LdsServer {
async fn list_plugin_tools(&self) -> Result<Vec<Tool>, McpError> {
let inner = self.state.read().await;
let global_dirs = inner.startup_global_dirs.clone();
let mut plugins = lds_recipe::list_global_plugins(&global_dirs)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
if let Some(recipe) = inner.recipe.as_ref() {
let project = recipe
.list_plugins()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut by_name: std::collections::HashMap<String, lds_recipe::PluginRecipe> =
plugins.into_iter().map(|p| (p.name.clone(), p)).collect();
for p in project {
by_name.insert(p.name.clone(), p);
}
plugins = by_name.into_values().collect();
plugins.sort_by(|a, b| a.name.cmp(&b.name));
}
Ok(plugins.into_iter().map(plugin_to_tool).collect())
}
async fn list_export_tools(&self) -> Vec<Tool> {
let inner = self.state.read().await;
let Some(registry) = inner.export_registry.clone() else {
return Vec::new();
};
drop(inner);
registry.list_tools().await
}
async fn try_export_call(
&self,
name: &str,
arguments: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<Option<CallToolResult>, McpError> {
let inner = self.state.read().await;
let Some(registry) = inner.export_registry.clone() else {
return Ok(None);
};
let router = inner.router.clone();
drop(inner);
let Some((route, upstream_tool)) = registry.resolve(name).await else {
return Ok(None);
};
let router = router.ok_or_else(no_session_error)?;
let args = serde_json::Value::Object(arguments.cloned().unwrap_or_default());
router
.call(&route, &upstream_tool, args)
.await
.map(Some)
.map_err(|e| McpError::internal_error(e.to_string(), None))
}
async fn try_plugin_call(
&self,
name: &str,
arguments: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<Option<CallToolResult>, McpError> {
let inner = self.state.read().await;
let Some(recipe) = inner.recipe.as_ref() else {
let is_builtin = Self::tool_router()
.list_all()
.iter()
.any(|t| t.name.as_ref() == name);
if is_builtin {
return Ok(None);
}
tracing::warn!(tool = name, "plugin call attempted without active session");
return Err(no_session_error());
};
let global_dirs = inner.startup_global_dirs.clone();
let mut plugins = lds_recipe::list_global_plugins(&global_dirs)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
plugins.extend(
recipe
.list_plugins()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
);
let Some(target) = plugins.iter().find(|p| p.name == name) else {
return Ok(None);
};
let arg_strings: Vec<String> = target
.parameters
.iter()
.map(|p| {
arguments
.and_then(|a| a.get(&p.name))
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_default()
})
.collect();
let last_present = arg_strings
.iter()
.rposition(|s| !s.is_empty())
.map(|i| i + 1)
.unwrap_or(0);
let positional: Vec<&str> = arg_strings[..last_present]
.iter()
.map(|s| s.as_str())
.collect();
let output = recipe
.run(name, &positional, &HashMap::new(), None)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&output)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(Some(CallToolResult::success(vec![Content::text(json)])))
}
fn new() -> Self {
let startup_cwd = std::env::current_dir().ok();
let cfg = Config::load_or_default();
let env_var = std::env::var_os("LDS_RECIPE_GLOBAL_DIRS");
let startup_global_dirs = Arc::new(resolve_startup_global_dirs(cfg, env_var));
Self {
state: Arc::new(RwLock::new(Inner {
lds: LdsState::new(),
git: None,
gh: None,
publicity: None,
recipe: None,
sandbox_fs: None,
sandbox_python: None,
journal: None,
outline: None,
router: None,
export_registry: None,
startup_cwd,
startup_global_dirs,
})),
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SessionStartReq {
root: String,
#[serde(default)]
alias: Option<String>,
#[serde(default)]
timeout_secs: Option<u64>,
#[serde(default)]
max_output: Option<usize>,
#[serde(default)]
global_recipe_dir: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SessionCreateReq {
root: String,
#[serde(default)]
alias: Option<String>,
#[serde(default)]
make_default: bool,
#[serde(default)]
timeout_secs: Option<u64>,
#[serde(default)]
max_output: Option<usize>,
#[serde(default)]
global_recipe_dir: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SessionKeyReq {
key: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SessionAliasSetReq {
key: String,
alias: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SessionAliasUnsetReq {
alias: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SessionDoctorReq {
#[serde(default)]
key: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PublicityReq {
#[serde(default)]
platform: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitLogReq {
#[serde(default = "default_max_count")]
max_count: usize,
#[serde(default)]
author: Option<String>,
#[serde(default)]
paths: Option<Vec<String>>,
#[serde(default)]
since: Option<SinceInput>,
#[serde(default)]
grep: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(untagged)]
enum SinceInput {
Epoch(i64),
Rfc3339(String),
}
impl SinceInput {
fn to_epoch(&self) -> Result<i64, String> {
match self {
SinceInput::Epoch(n) => Ok(*n),
SinceInput::Rfc3339(s) => parse_since_string(s),
}
}
}
fn parse_since_string(s: &str) -> Result<i64, String> {
let trimmed = s.trim();
if let Ok(n) = trimmed.parse::<i64>() {
return Ok(n);
}
chrono::DateTime::parse_from_rfc3339(trimmed)
.map(|dt| dt.timestamp())
.map_err(|e| format!("since: not a valid unix epoch or RFC 3339 timestamp: {e}"))
}
fn default_max_count() -> usize {
20
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitCommitReq {
working_dir: String,
message: String,
#[serde(default)]
paths: Option<Vec<String>>,
#[serde(default)]
other_staged: Option<String>,
#[serde(default)]
force_dot: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitWorktreeAddReq {
name: String,
branch: String,
#[serde(default)]
base_branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitWorktreeRemoveReq {
name: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitMergeReq {
branch: String,
into_branch: String,
#[serde(default)]
working_dir: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitBranchDeleteReq {
branch: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitFetchReq {
#[serde(default)]
remote: Option<String>,
#[serde(default)]
refspec: Option<String>,
#[serde(default)]
prune: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitBranchStatusReq {
branch: String,
base: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitUnpushedCommitsReq {
branch: String,
#[serde(default = "default_origin")]
remote: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitIsPushedReq {
commit: String,
#[serde(default = "default_origin")]
remote: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitTagPushedReq {
tag: String,
#[serde(default = "default_origin")]
remote: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitWorktreeStateReq {
#[serde(default)]
branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitDiffReq {
#[serde(default)]
staged: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitResetReq {
working_dir: String,
mode: String,
target: String,
#[serde(default)]
force: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitStashShowReq {
index: usize,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitStashRestoreReq {
working_dir: String,
sha: String,
#[serde(default)]
message: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GitStashTxReq {
working_dir: String,
index: usize,
#[serde(default)]
expected_sha: Option<String>,
}
fn default_origin() -> String {
"origin".to_string()
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RecipeRunReq {
recipe: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
content: HashMap<String, String>,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxWriteReq {
path: String,
content: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxEditReq {
path: String,
old_string: String,
new_string: String,
#[serde(default)]
replace_all: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxAppendReq {
path: String,
content: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxReadReq {
path: String,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxLinesReq {
path: String,
#[serde(default)]
lines: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxRollbackReq {
path: String,
#[serde(default)]
snapshot_id: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxHistoryReq {
path: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxPythonReq {
script: String,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SandboxPythonFileReq {
path: String,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RecipeLogsReq {
#[serde(default)]
task_id: Option<String>,
#[serde(default)]
tail: Option<usize>,
}
fn default_limit_30() -> usize {
30
}
fn default_tail_20() -> usize {
20
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhPrListReq {
#[serde(default = "default_limit_30")]
limit: usize,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhPrViewReq {
number: u64,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhPrDiffReq {
number: u64,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhIssueListReq {
#[serde(default = "default_limit_30")]
limit: usize,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhIssueViewReq {
number: u64,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhRunListReq {
#[serde(default = "default_limit_30")]
limit: usize,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhRunViewReq {
run_id: u64,
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhRunLogFailedReq {
run_id: u64,
#[serde(default)]
repo: Option<String>,
#[serde(default = "default_tail_20")]
tail_lines: usize,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhRunJobsReq {
run_id: u64,
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhReleaseViewReq {
tag: String,
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhReleaseListReq {
#[serde(default = "default_limit_30")]
limit: usize,
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhWorkflowListReq {
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhWorkflowViewReq {
name_or_id: String,
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GhPrChecksReq {
number: u64,
#[serde(default)]
repo: Option<String>,
}
fn default_mcp_call_args() -> serde_json::Value {
serde_json::Value::Object(serde_json::Map::new())
}
#[derive(Debug, Deserialize, JsonSchema)]
struct McpCallReq {
uri: String,
#[serde(default = "default_mcp_call_args")]
args: serde_json::Value,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct McpRouteRegisterReq {
name: String,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct McpRouteRemoveReq {
name: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PackCreateReq {
#[serde(default)]
root: Option<String>,
out: String,
#[serde(default)]
level: Option<i32>,
#[serde(default)]
dry_run: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PackRestoreReq {
archive: String,
into: String,
#[serde(default)]
force: Option<bool>,
#[serde(default)]
dry_run: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PackInspectReq {
archive: String,
#[serde(default)]
files: Option<bool>,
}
fn pack_rules() -> Result<lds_pack::PackRules, McpError> {
let cfg = Config::load_or_default();
lds_pack::PackRules::new(&lds_pack::RuleOverrides {
secret_globs: cfg.pack.secret_globs.clone(),
cache_dirs: cfg.pack.cache_dirs.clone(),
keep: cfg.pack.keep.clone(),
no_link_report: cfg.pack.no_link_report.clone(),
})
.map_err(|e| McpError::invalid_params(e.to_string(), None))
}
fn no_session_error() -> McpError {
McpError::internal_error("no session", None)
}
fn parse_other_staged(mode: Option<&str>) -> Result<OtherStagedMode, McpError> {
match mode {
None | Some("") => Ok(OtherStagedMode::default()),
Some("stop") => Ok(OtherStagedMode::Stop),
Some("restage") => Ok(OtherStagedMode::Restage),
Some(other) => Err(McpError::internal_error(
format!("unknown other_staged mode {other:?} (expected stop|restage)"),
None,
)),
}
}
fn json_result<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
let text = serde_json::to_string_pretty(value)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(text)]))
}
fn user_config_path() -> PathBuf {
lds_core::config::user_config_path()
.unwrap_or_else(|| PathBuf::from("/nonexistent-home/.config/lds/config.toml"))
}
fn start_session_locally(
inner: &mut Inner,
config: SessionConfig,
) -> Result<Arc<Session>, McpError> {
let session = inner
.lds
.start_session(config)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
inner.git = Some(GitModule::new(Arc::clone(&session)));
inner.gh = Some(GhModule::new(Arc::clone(&session)));
inner.publicity = Some(PublicityModule::new(Arc::clone(&session)));
inner.recipe = Some(RecipeModule::new(Arc::clone(&session)));
inner.sandbox_fs = Some(
SandboxFs::new(session.root())
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
);
inner.sandbox_python = Some(SandboxPython::new(session.root()));
inner.journal = Some({
let file_projection = if std::env::var_os("LDS_JOURNAL_FILE_ENABLE").is_some() {
match std::env::var_os("LDS_JOURNAL_FILE_OUTPUT_PATH") {
Some(raw) => {
let p = PathBuf::from(raw);
Some(if p.is_absolute() {
p
} else {
session.root().join(p)
})
}
None => Some(session.root().join("workspace").join("journal.md")),
}
} else {
None
};
JournalModule::new(session.root().to_path_buf(), file_projection)
.map_err(|e| McpError::internal_error(e.to_string(), None))?
});
inner.outline = {
let shelf_dir = std::env::var_os("LDS_OUTLINE_SHELF_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".config/outline-mcp/books")
});
match OutlineModule::new(shelf_dir.clone()) {
Ok(m) => Some(m),
Err(e) => {
tracing::warn!(
error = %e,
shelf_dir = %shelf_dir.display(),
"OutlineModule init failed; outline_* tools disabled for this session"
);
None
}
}
};
Ok(session)
}
async fn wire_router_and_exports(
state: &Arc<RwLock<Inner>>,
session: &Arc<Session>,
) -> Result<(), McpError> {
let user_path = user_config_path();
let project_path = session.root().join("config.toml");
let session_root = session.root().to_path_buf();
let (routes, exports) =
spawn_blocking(move || RouteConfig::load_all(&user_path, &project_path, &session_root))
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let router = McpRouter::from_configs(routes);
let static_tool_names: Vec<String> = LdsServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
let export_registry = ExportRegistry::from_declarations(exports);
export_registry
.refresh(&router, &static_tool_names)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut inner = state.write().await;
inner.router = Some(router);
inner.export_registry = Some(export_registry);
Ok(())
}
fn is_project_root(path: &std::path::Path) -> bool {
path.join(".git").exists() || path.join("justfile").exists()
}
async fn maybe_auto_start_session(
state: &Arc<RwLock<Inner>>,
) -> Result<Option<Arc<Session>>, McpError> {
let started_session = {
let mut inner = state.write().await;
if inner.lds.session().is_err()
&& let Some(cwd) = inner.startup_cwd.clone()
&& is_project_root(&cwd)
{
let global_recipe_dirs = (*inner.startup_global_dirs).clone();
let config = SessionConfig {
root: cwd,
global_recipe_dirs,
..Default::default()
};
Some(start_session_locally(&mut inner, config)?)
} else {
None
}
};
if let Some(session) = &started_session {
wire_router_and_exports(state, session).await?;
}
Ok(started_session)
}
#[tool_router]
impl LdsServer {
#[tool(
description = "Initialize session with project root (with optional alias). Must be called first. Replaces the implicit default session."
)]
async fn session_start(
&self,
Parameters(req): Parameters<SessionStartReq>,
peer: rmcp::Peer<RoleServer>,
) -> Result<CallToolResult, McpError> {
let config = {
let inner = self.state.read().await;
let startup_dirs = inner.startup_global_dirs.clone();
let mut global_recipe_dirs: Vec<PathBuf> = req
.global_recipe_dir
.map(|s| vec![PathBuf::from(s)])
.unwrap_or_default();
global_recipe_dirs.extend(startup_dirs.iter().cloned());
SessionConfig {
root: req.root.into(),
timeout_secs: req.timeout_secs,
max_output: req.max_output,
alias: req.alias.clone(),
global_recipe_dirs,
worktrees_dir: None,
}
};
let session = {
let mut inner = self.state.write().await;
start_session_locally(&mut inner, config)?
};
wire_router_and_exports(&self.state, &session).await?;
if let Err(e) = peer.notify_tool_list_changed().await {
tracing::warn!(error = %e, "notify_tool_list_changed failed after session_start");
}
json_result(&serde_json::json!({
"session_id": session.id(),
"alias": session.alias(),
"root": session.root().display().to_string(),
"is_default": true,
}))
}
#[tool(
description = "Show git working tree status as JSON (branch, head_sha, staged/unstaged/untracked arrays, clean flag)"
)]
async fn git_status(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.status()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Show git commit log as JSON ({commits: [{sha, short_sha, summary, author, timestamp}]}). \
Filters (all optional, AND-combined): `author` (case-sensitive substring against \"Name <email>\"), \
`paths` (git pathspec list; commit kept when it touches at least one entry), \
`since` (cutoff for commit.author_time; accepts unix epoch integer or RFC 3339 string like \"2026-07-14T00:00:00Z\"), \
`grep` (case-sensitive substring against the full commit message). \
`max_count` caps the post-filter result count."
)]
async fn git_log(
&self,
Parameters(req): Parameters<GitLogReq>,
) -> Result<CallToolResult, McpError> {
let since = match req.since {
Some(input) => Some(
input
.to_epoch()
.map_err(|e| McpError::invalid_params(e, None))?,
),
None => None,
};
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let filters = LogFilters {
max_count: req.max_count,
author: req.author,
paths: req.paths,
since,
grep: req.grep,
};
let out = git
.log(filters)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Show git diff as JSON. staged=false (default) → index vs worktree; staged=true → HEAD vs index (git diff --cached)"
)]
async fn git_diff(
&self,
Parameters(req): Parameters<GitDiffReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.diff(req.staged)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "List git worktrees as JSON, each with session ownership flag")]
async fn git_worktree_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.worktree_list()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Create a session-owned git worktree on a new branch. \
The worktree is placed under the session-scoped worktrees dir \
resolved by SessionConfig::worktrees_dir (explicit config → \
env LDS_WORKTREES_DIR → session default). The target must be \
gitignored in the parent repo.")]
async fn git_worktree_add(
&self,
Parameters(req): Parameters<GitWorktreeAddReq>,
) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
let git = inner.git.as_mut().ok_or_else(no_session_error)?;
let out = git
.worktree_add(&req.name, &req.branch, req.base_branch.as_deref())
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Remove a session-owned git worktree")]
async fn git_worktree_remove(
&self,
Parameters(req): Parameters<GitWorktreeRemoveReq>,
) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
let git = inner.git.as_mut().ok_or_else(no_session_error)?;
let out = git
.worktree_remove(&req.name)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Commit changes in a session-owned working directory. \
`paths` omitted or empty: sweeps every change via `git add -A`. \
`paths` set: commits exactly those paths; when the index carries other \
staged paths, `other_staged` decides — `stop` (default) fails leaving \
state unchanged, `restage` unstages the intruders, commits `paths`, then \
re-stages them so pre-existing staged work survives. Dotfile / dot-dir \
safeguard is on by default: untracked-and-not-in-`.gitignore` entries \
(`.env`, the contents of a freshly-dropped dot-directory, `foo/.hidden`, etc.) are skipped \
from staging and reported in the response `dotfile_warnings` / \
`dotfile_skipped`; tracked dotfile changes are still committed but \
reported the same way so pre-publish review can catch unintended edits \
to `.gitignore` / `.github/workflows/*.yml` / etc. Pass `force_dot: true` \
to suppress the safeguard entirely.")]
async fn git_commit(
&self,
Parameters(req): Parameters<GitCommitReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let working_dir = PathBuf::from(&req.working_dir);
let other_staged = parse_other_staged(req.other_staged.as_deref())?;
let out = git
.commit(
&working_dir,
&req.message,
req.paths.as_deref(),
other_staged,
req.force_dot.unwrap_or(false),
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Merge a branch into another in a session-owned working directory")]
async fn git_merge(
&self,
Parameters(req): Parameters<GitMergeReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let session = inner
.lds
.session()
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let working_dir = req
.working_dir
.map(PathBuf::from)
.unwrap_or_else(|| session.root().to_path_buf());
let out = git
.merge(&req.branch, &req.into_branch, &working_dir)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Delete a session-owned branch")]
async fn git_branch_delete(
&self,
Parameters(req): Parameters<GitBranchDeleteReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.branch_delete(&req.branch)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Fetch from a remote (default origin)")]
async fn git_fetch(
&self,
Parameters(req): Parameters<GitFetchReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.fetch(req.remote.as_deref(), req.refspec.as_deref(), req.prune)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "List git remotes as JSON ({remotes: [{name, fetch_url, push_url}]})")]
async fn git_remote_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.remote_list()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Show ahead/behind counts between branch and base (e.g. origin/main)")]
async fn git_branch_status(
&self,
Parameters(req): Parameters<GitBranchStatusReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.branch_status(&req.branch, &req.base)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "List commits on branch not yet pushed to <remote>/<branch>")]
async fn git_unpushed_commits(
&self,
Parameters(req): Parameters<GitUnpushedCommitsReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.unpushed_commits(&req.branch, &req.remote)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Check whether a commit is reachable from any remote ref")]
async fn git_is_pushed(
&self,
Parameters(req): Parameters<GitIsPushedReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.is_pushed(&req.commit, &req.remote)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Check whether a tag is pushed to a remote via git ls-remote --tags")]
async fn git_tag_pushed(
&self,
Parameters(req): Parameters<GitTagPushedReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.tag_pushed(&req.tag, &req.remote)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Snapshot of a worktree state as JSON (branch, tracking, ahead, behind, uncommitted, clean, sync)"
)]
async fn git_worktree_state(
&self,
Parameters(req): Parameters<GitWorktreeStateReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.worktree_state(req.branch.as_deref())
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Reset HEAD in a session-owned working directory. mode = soft | mixed | hard. \
mode=hard is refused when the repo has stash entries AND the working tree is dirty — that shape \
means an applied stash is in flight, and --hard would destroy it without a trace. Undo the apply \
with git_stash_abort (the entry survives), or pass force=true when the reset is genuinely intended."
)]
async fn git_reset(
&self,
Parameters(req): Parameters<GitResetReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let mode = match req.mode.as_str() {
"soft" => ResetMode::Soft,
"mixed" => ResetMode::Mixed,
"hard" => ResetMode::Hard,
other => {
return Err(McpError::internal_error(
format!("unknown reset mode {other:?} (expected soft|mixed|hard)"),
None,
));
}
};
let working_dir = PathBuf::from(&req.working_dir);
let out = git
.reset(&working_dir, mode, &req.target, req.force)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "List git stash entries as JSON ({stashes: [{index, sha, message, has_untracked}]}). \
Read-only. `sha` is stable while `index` shifts on every drop — carry the sha into the \
apply / abort / finalize calls as expected_sha."
)]
async fn git_stash_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.stash_list()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Inspect one stash entry without applying it: patch against the commit it was \
taken on, the tracked paths it touches, and the untracked paths it carries (git stash push -u). \
Read-only."
)]
async fn git_stash_show(
&self,
Parameters(req): Parameters<GitStashShowReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let out = git
.stash_show(req.index)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Apply a stash entry into a session-owned working directory, KEEPING the entry \
(step 1 of 2). Requires a clean working tree: any staged or unstaged change is refused so the \
applied content never mixes with hand edits (untracked files are allowed). Also refused when the \
entry's untracked files already exist on disk. On conflict the working tree is rolled back \
automatically and the entry is left intact. Verify the result, then call git_stash_finalize to \
drop the entry, or git_stash_abort to undo. There is no pop tool by design — pop would drop the \
entry before anyone could check the apply."
)]
async fn git_stash_apply(
&self,
Parameters(req): Parameters<GitStashTxReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let working_dir = PathBuf::from(&req.working_dir);
let out = git
.stash_apply(&working_dir, req.index, req.expected_sha)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Undo an applied stash: every path the entry touches goes back to its HEAD \
state and the entry itself is kept. Edits layered on top of the applied stash are discarded too \
(the apply's clean-tree precondition is what makes that safe). Use this instead of \
git_reset mode=hard when a stash apply turned out wrong."
)]
async fn git_stash_abort(
&self,
Parameters(req): Parameters<GitStashTxReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let working_dir = PathBuf::from(&req.working_dir);
let out = git
.stash_abort(&working_dir, req.index, req.expected_sha)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Drop a stash entry after its applied content has been verified (step 2 of 2). \
Returns dropped_sha — the stash commit stays reachable until git gc prunes it, so \
`git stash apply <dropped_sha>` still recovers the content. Record it; it is the difference \
between dropped and lost. This is the only drop path: bare drop / clear are not exposed."
)]
async fn git_stash_finalize(
&self,
Parameters(req): Parameters<GitStashTxReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let working_dir = PathBuf::from(&req.working_dir);
let out = git
.stash_finalize(&working_dir, req.index, req.expected_sha)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Put a dropped stash entry back on refs/stash: pass the dropped_sha that \
git_stash_finalize returned. A drop only removes the reflog entry — the commit itself survives \
unreferenced until git gc prunes it, so this works until gc runs (and not after). The restored \
entry lands at stash@{0}, keeping its original message unless `message` overrides it. Refused \
when the sha is not stash-shaped (>= 2 parents) or is already in the stash list."
)]
async fn git_stash_restore(
&self,
Parameters(req): Parameters<GitStashRestoreReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let git = inner.git.as_ref().ok_or_else(no_session_error)?;
let working_dir = PathBuf::from(&req.working_dir);
let out = git
.stash_restore(&working_dir, &req.sha, req.message)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(
description = "Adopt orphan worktrees under the session-scoped worktrees \
dir (see git_worktree_add) that were left behind by a \
previous session."
)]
async fn git_session_release(&self) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
let git = inner.git.as_mut().ok_or_else(no_session_error)?;
let out = git
.session_release()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&out)
}
#[tool(description = "Check gh CLI authentication status")]
async fn gh_auth_status(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.auth_status()
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "List GitHub pull requests (read-only). Returns JSON array of PRs with number, title, state, author."
)]
async fn gh_pr_list(
&self,
Parameters(req): Parameters<GhPrListReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.pr_list(req.limit)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(description = "View a single GitHub pull request as JSON (read-only).")]
async fn gh_pr_view(
&self,
Parameters(req): Parameters<GhPrViewReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.pr_view(req.number)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(description = "Show diff of a GitHub pull request (read-only).")]
async fn gh_pr_diff(
&self,
Parameters(req): Parameters<GhPrDiffReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.pr_diff(req.number)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "List GitHub issues (read-only). Returns JSON array of issues with number, title, state."
)]
async fn gh_issue_list(
&self,
Parameters(req): Parameters<GhIssueListReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.issue_list(req.limit)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(description = "View a single GitHub issue as JSON (read-only).")]
async fn gh_issue_view(
&self,
Parameters(req): Parameters<GhIssueViewReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.issue_view(req.number)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(description = "View repository metadata as JSON (read-only).")]
async fn gh_repo_view(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.repo_view()
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "Classify per-platform publicity for the current session root. Returns one result per platform (github / crates) with a canonical value in {PUBLIC, PRIVATE, INTERNAL, LOCAL, FORKED, AMBIGUOUS, UNKNOWN} plus a reason and detail JSON. Pass `platform` (optional: github / crates) to restrict to a single platform; omit to probe all applicable."
)]
async fn publicity(
&self,
Parameters(req): Parameters<PublicityReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let publicity = inner.publicity.as_ref().ok_or_else(no_session_error)?;
let results = match req.platform.as_deref() {
None => publicity.detect_all().await,
Some(label) => {
let p = Platform::parse(label).ok_or_else(|| {
McpError::invalid_params(
format!(
"unknown platform '{label}' (expected: github / gh / crates / crates.io / cargo)"
),
None,
)
})?;
vec![publicity.detect(p).await]
}
};
let body = serde_json::json!({ "results": results });
let text = serde_json::to_string_pretty(&body)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(text)]))
}
#[tool(
description = "List GitHub Actions workflow runs (read-only). Returns JSON array with status, conclusion, workflowName."
)]
async fn gh_run_list(
&self,
Parameters(req): Parameters<GhRunListReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.run_list(req.limit)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "Get details of a single GitHub Actions workflow run (read-only). Returns JSON with run status, conclusion, and job summary."
)]
async fn gh_run_view(
&self,
Parameters(req): Parameters<GhRunViewReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.run_view(req.run_id, req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "Get logs of failed steps in a GitHub Actions workflow run (read-only). Returns JSON with failed_steps array containing job_name, step_name, and log_tail."
)]
async fn gh_run_log_failed(
&self,
Parameters(req): Parameters<GhRunLogFailedReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.run_log_failed(req.run_id, req.repo, Some(req.tail_lines))
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "List jobs of a GitHub Actions workflow run (read-only). Returns JSON array with job name, status, and step details."
)]
async fn gh_run_jobs(
&self,
Parameters(req): Parameters<GhRunJobsReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.run_jobs(req.run_id, req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "Get details of a GitHub release by tag (read-only). Returns JSON with tag name, release notes, and asset list."
)]
async fn gh_release_view(
&self,
Parameters(req): Parameters<GhReleaseViewReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.release_view(req.tag, req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "List GitHub releases (read-only). Returns JSON array with tag, name, and published date."
)]
async fn gh_release_list(
&self,
Parameters(req): Parameters<GhReleaseListReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.release_list(req.limit, req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "List GitHub Actions workflows in the repository (read-only). Returns JSON array with workflow name, id, and state."
)]
async fn gh_workflow_list(
&self,
Parameters(req): Parameters<GhWorkflowListReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.workflow_list(req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "Get details of a GitHub Actions workflow by name or id (read-only). Returns JSON with workflow metadata and recent run summary."
)]
async fn gh_workflow_view(
&self,
Parameters(req): Parameters<GhWorkflowViewReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.workflow_view(req.name_or_id, req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(
description = "List CI check runs for a GitHub pull request (read-only). Returns JSON array with check name, status, and conclusion."
)]
async fn gh_pr_checks(
&self,
Parameters(req): Parameters<GhPrChecksReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let gh = inner.gh.as_ref().ok_or_else(no_session_error)?;
let out = gh
.pr_checks(req.number, req.repo)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(out)]))
}
#[tool(description = "List available justfile recipes")]
async fn recipe_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let recipe = inner.recipe.as_ref().ok_or_else(no_session_error)?;
let recipes = recipe
.list()
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&recipes)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "Run a justfile recipe")]
async fn recipe_run(
&self,
Parameters(req): Parameters<RecipeRunReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let recipe = inner.recipe.as_ref().ok_or_else(no_session_error)?;
let args_refs: Vec<&str> = req.args.iter().map(|s| s.as_str()).collect();
let output = recipe
.run(&req.recipe, &args_refs, &req.content, req.timeout_secs)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&output)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Query recipe execution logs. By ID returns full record; without ID returns recent summaries."
)]
async fn recipe_logs(
&self,
Parameters(req): Parameters<RecipeLogsReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let recipe = inner.recipe.as_ref().ok_or_else(no_session_error)?;
let logs = recipe.logs();
let json = if let Some(ref id) = req.task_id {
let entry = logs
.get(id)
.ok_or_else(|| McpError::internal_error(format!("log not found: {id}"), None))?;
if let Some(tail) = req.tail {
let lines: Vec<&str> = entry.stdout.lines().collect();
let start = lines.len().saturating_sub(tail);
let mut trimmed = entry.clone();
trimmed.stdout = lines[start..].join("\n");
serde_json::to_string_pretty(&trimmed)
} else {
serde_json::to_string_pretty(&entry)
}
} else {
let recent = logs.recent(10);
let summaries: Vec<lds_recipe::RecipeOutputSummary> =
recent.iter().map(Into::into).collect();
serde_json::to_string_pretty(&summaries)
};
let json = json.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Sandboxed write: full file content. Auto-snapshots pre-state for rollback."
)]
async fn sandbox_write(
&self,
Parameters(req): Parameters<SandboxWriteReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let result = fs
.write(&req.path, &req.content)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Sandboxed edit: replace old_string with new_string. Auto-snapshots pre-state."
)]
async fn sandbox_edit(
&self,
Parameters(req): Parameters<SandboxEditReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let result = fs
.edit(&req.path, &req.old_string, &req.new_string, req.replace_all)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "Sandboxed append: add content to end of file. Auto-snapshots pre-state.")]
async fn sandbox_append(
&self,
Parameters(req): Parameters<SandboxAppendReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let result = fs
.append(&req.path, &req.content)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "Sandboxed read: read file with optional offset (line) and limit.")]
async fn sandbox_read(
&self,
Parameters(req): Parameters<SandboxReadReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let content = fs
.read(&req.path, req.offset, req.limit)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(content)]))
}
#[tool(description = "Sandboxed head: read first N lines (default 20).")]
async fn sandbox_head(
&self,
Parameters(req): Parameters<SandboxLinesReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let content = fs
.head(&req.path, req.lines)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(content)]))
}
#[tool(description = "Sandboxed tail: read last N lines (default 20).")]
async fn sandbox_tail(
&self,
Parameters(req): Parameters<SandboxLinesReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let content = fs
.tail(&req.path, req.lines)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(content)]))
}
#[tool(
description = "Rollback file to a prior snapshot. Omit snapshot_id to restore the most recent."
)]
async fn sandbox_rollback(
&self,
Parameters(req): Parameters<SandboxRollbackReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let result = fs
.rollback(&req.path, req.snapshot_id.as_deref())
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "List snapshot history for a file (newest first).")]
async fn sandbox_history(
&self,
Parameters(req): Parameters<SandboxHistoryReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let fs = inner.sandbox_fs.as_ref().ok_or_else(no_session_error)?;
let history = fs
.history(&req.path)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&history)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Run Python script in a sandboxed subprocess with 3-layer preamble guard (module deny + import guard + os attr removal)."
)]
async fn sandbox_python(
&self,
Parameters(req): Parameters<SandboxPythonReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let base = inner
.sandbox_python
.as_ref()
.ok_or_else(no_session_error)?
.clone();
let py = match req.timeout_secs {
Some(secs) => base.with_timeout(secs),
None => base,
};
let result = py
.execute(&req.script)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Run a Python file in the sandboxed subprocess. Uses the same preamble guard as sandbox_python."
)]
async fn sandbox_python_file(
&self,
Parameters(req): Parameters<SandboxPythonFileReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let base = inner
.sandbox_python
.as_ref()
.ok_or_else(no_session_error)?
.clone();
let py = match req.timeout_secs {
Some(secs) => base.with_timeout(secs),
None => base,
};
let result = py
.execute_file(std::path::Path::new(&req.path))
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "Show current session state (id, root, mode, justfile paths)")]
async fn session_info(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let session = inner
.lds
.session()
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let resolve_info: Vec<String> = inner
.recipe
.as_ref()
.map(|r| {
r.resolve_chain()
.iter()
.map(|(level, path)| format!("{level:?}: {}", path.display()))
.collect()
})
.unwrap_or_default();
let binaries = check_binaries(&["git", "just", "python3", "codedash", "rg"]);
let binary_lines: Vec<String> = binaries
.iter()
.map(|b| {
let status = if b.available { "available" } else { "MISSING" };
let path = b.path.as_deref().unwrap_or("-");
format!(" - {}: {status} ({path})", b.name)
})
.collect();
let global_dirs_display = {
let dirs = session.global_recipe_dirs();
if dirs.is_empty() {
"(default)".to_string()
} else {
dirs.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
};
let info = format!(
"session_id: {}\nroot: {}\nglobal_recipe_dirs: {}\njustfiles:\n{}\nexternal tools:\n{}",
session.id(),
session.root().display(),
global_dirs_display,
resolve_info
.iter()
.map(|s| format!(" - {s}"))
.collect::<Vec<_>>()
.join("\n"),
binary_lines.join("\n"),
);
Ok(CallToolResult::success(vec![Content::text(info)]))
}
#[tool(
description = "Create an additional session bound to an arbitrary root, optionally with a human-readable alias. \
Existing default session is preserved unless make_default=true."
)]
async fn session_create(
&self,
Parameters(req): Parameters<SessionCreateReq>,
) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
let startup_dirs = inner.startup_global_dirs.clone();
let mut global_recipe_dirs: Vec<PathBuf> = req
.global_recipe_dir
.map(|s| vec![PathBuf::from(s)])
.unwrap_or_default();
global_recipe_dirs.extend(startup_dirs.iter().cloned());
let config = SessionConfig {
root: req.root.into(),
timeout_secs: req.timeout_secs,
max_output: req.max_output,
alias: req.alias.clone(),
global_recipe_dirs,
worktrees_dir: None,
};
let session = inner
.lds
.create_session(config, req.make_default)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&serde_json::json!({
"session_id": session.id(),
"alias": session.alias(),
"root": session.root().display().to_string(),
"is_default": inner.lds.default_session_id() == Some(session.id()),
}))
}
#[tool(
description = "List all live sessions in the ledger (id, alias, root, timestamps, is_default)."
)]
async fn session_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let entries: Vec<serde_json::Value> = inner
.lds
.list_sessions()
.into_iter()
.map(|e| {
serde_json::json!({
"session_id": e.session_id,
"alias": e.alias,
"root": e.root.display().to_string(),
"created_at": e.created_at,
"last_used_at": e.last_used_at,
"is_default": e.is_default,
})
})
.collect();
json_result(&serde_json::json!({ "sessions": entries }))
}
#[tool(
description = "Call an external MCP tool via routed subprocess. uri = '<route>://<tool>'.",
annotations(idempotent_hint = false)
)]
async fn mcp_call(
&self,
Parameters(req): Parameters<McpCallReq>,
) -> Result<CallToolResult, McpError> {
if !req.args.is_object() {
return Err(McpError::invalid_params("args must be an object", None));
}
let inner = self.state.read().await;
let router = inner.router.clone().ok_or_else(no_session_error)?;
drop(inner);
router
.call_uri(&req.uri, req.args)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))
}
#[tool(
description = "List registered MCP routes for the active session.",
annotations(idempotent_hint = true, destructive_hint = false)
)]
async fn mcp_route_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let router = inner.router.clone().ok_or_else(no_session_error)?;
drop(inner);
let routes = router.list_routes().await;
json_result(&serde_json::json!({ "routes": routes }))
}
#[tool(
description = "Register or replace an MCP route. In-memory only; not persisted to config.toml.",
annotations(idempotent_hint = true, destructive_hint = false)
)]
async fn mcp_route_register(
&self,
Parameters(req): Parameters<McpRouteRegisterReq>,
peer: rmcp::Peer<RoleServer>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let router = inner.router.clone().ok_or_else(no_session_error)?;
drop(inner);
let name = req.name.clone();
let route = RouteConfig {
name: req.name,
command: req.command,
args: req.args,
env: req.env,
timeout_secs: req.timeout_secs.unwrap_or(30),
};
router
.register(route)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
if let Err(e) = peer.notify_tool_list_changed().await {
tracing::warn!(error = %e, "notify_tool_list_changed failed after mcp_route_register");
}
json_result(&serde_json::json!({ "registered": name }))
}
#[tool(
description = "Remove an MCP route and terminate its subprocess.",
annotations(idempotent_hint = true, destructive_hint = true)
)]
async fn mcp_route_remove(
&self,
Parameters(req): Parameters<McpRouteRemoveReq>,
peer: rmcp::Peer<RoleServer>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let router = inner.router.clone().ok_or_else(no_session_error)?;
drop(inner);
router
.remove(&req.name)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
if let Err(e) = peer.notify_tool_list_changed().await {
tracing::warn!(error = %e, "notify_tool_list_changed failed after mcp_route_remove");
}
json_result(&serde_json::json!({ "removed": req.name }))
}
#[tool(
description = "List the session's currently materialized `[[export]]` tools.",
annotations(idempotent_hint = true, destructive_hint = false)
)]
async fn mcp_export_list(&self) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let registry = inner.export_registry.clone().ok_or_else(no_session_error)?;
drop(inner);
let tools = registry.list_tools().await;
json_result(&serde_json::json!({ "exports": tools }))
}
#[tool(
description = "Re-fetch upstream tool schemas for every declared `[[export]]` route and replace the materialized export tool set.",
annotations(idempotent_hint = false, destructive_hint = false)
)]
async fn mcp_export_refresh(
&self,
peer: rmcp::Peer<RoleServer>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let registry = inner.export_registry.clone().ok_or_else(no_session_error)?;
let router = inner.router.clone().ok_or_else(no_session_error)?;
drop(inner);
let static_tool_names: Vec<String> = Self::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
registry
.refresh(&router, &static_tool_names)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let tools = registry.list_tools().await;
if let Err(e) = peer.notify_tool_list_changed().await {
tracing::warn!(error = %e, "notify_tool_list_changed failed after mcp_export_refresh");
}
json_result(&serde_json::json!({ "exports": tools }))
}
#[tool(description = "Describe a single session by id or alias.")]
async fn session_describe(
&self,
Parameters(req): Parameters<SessionKeyReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let entry = inner
.lds
.describe(&req.key)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&serde_json::json!({
"session_id": entry.session_id,
"alias": entry.alias,
"root": entry.root.display().to_string(),
"created_at": entry.created_at,
"last_used_at": entry.last_used_at,
"is_default": entry.is_default,
}))
}
#[tool(description = "Assign (or change) the alias of an existing session.")]
async fn session_alias_set(
&self,
Parameters(req): Parameters<SessionAliasSetReq>,
) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
inner
.lds
.set_alias(&req.key, req.alias.clone())
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&serde_json::json!({ "ok": true, "alias": req.alias }))
}
#[tool(description = "Remove an alias from the ledger; the underlying session is preserved.")]
async fn session_alias_unset(
&self,
Parameters(req): Parameters<SessionAliasUnsetReq>,
) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
inner
.lds
.unset_alias(&req.alias)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&serde_json::json!({ "ok": true }))
}
#[tool(description = "Close a session by id or alias and remove it from the ledger.")]
async fn session_close(
&self,
Parameters(req): Parameters<SessionKeyReq>,
) -> Result<CallToolResult, McpError> {
let mut inner = self.state.write().await;
inner
.lds
.close(&req.key)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&serde_json::json!({ "ok": true }))
}
#[tool(
description = "Run health checks (root-exists / git-bound / journal-db-writable / stale-lock / \
ownership-drift / root-conflict / ledger-leak) on one or every session. \
Pass key=\"all\" or omit to scan every session."
)]
async fn session_doctor(
&self,
Parameters(req): Parameters<SessionDoctorReq>,
) -> Result<CallToolResult, McpError> {
let inner = self.state.read().await;
let key = req.key.unwrap_or_else(|| "all".to_string());
let report_value = |r: &lds_core::DoctorReport| {
let checks: Vec<serde_json::Value> = r
.checks
.iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"status": c.status.as_str(),
"evidence": c.evidence,
})
})
.collect();
serde_json::json!({
"session_id": r.session_id,
"alias": r.alias,
"verdict": r.verdict.as_str(),
"checks": checks,
})
};
if key == "all" {
let mut reports: Vec<serde_json::Value> = Vec::new();
for entry in inner.lds.list_sessions() {
let r = inner
.lds
.doctor(&entry.session_id)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
reports.push(report_value(&r));
}
json_result(&serde_json::json!({ "reports": reports }))
} else {
let r = inner
.lds
.doctor(&key)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&report_value(&r))
}
}
#[tool(
description = "Pack a whole project — the `.git` directory itself plus untracked local state \
(workspace/, agent and editor dotfiles, sandbox snapshots) — into one zstd-compressed archive. \
`.git` is copied wholesale, so stashes, the reflog, local-only branches and unreferenced objects \
all travel (a `git bundle` would drop them). Secrets (.env, *.pem, credentials.toml, …) are \
reported but never packed; caches (target/, node_modules/, …) are dropped, each recorded with the \
file count and byte size that went with it so a `cache_dirs` entry aimed at the wrong directory is \
visible rather than a single indistinguishable line; symlinks are stored \
as links, never followed, and every one is reported — a link breaks when the project lands \
elsewhere, so it is something to act on. Extend the secret/cache lists via `[pack]` in \
~/.config/lds/config.toml, where a glob is scoped the way `.gitignore` scopes one — no `/` matches \
the file name at any depth, a `/` anchors it to that path, so `keep = [\"docs/samples/*.pem\"]` \
carries the sample without carrying every private key. `no_link_report` there names paths whose \
links are expected by design \
(a shared dotfile tree), packing them without reporting them. Nothing is suppressed unless \
configured, and every rule that suppressed something is echoed back in `no_link_report_applied`, so \
an empty `symlinks` list is never ambiguous. `kept_over_secret` names any file a `keep` glob carried \
past the secret rules. Set `dry_run` to classify and report without writing anything. \
Returns JSON {out_path, dry_run, stats, skipped_secret, skipped_cache, skipped_noise, symlinks, \
worktrees, worktree_of, no_link_report_applied, kept_over_secret}. Nothing is dropped without a \
record: `skipped_noise` accounts even for `.DS_Store`, so a path present in the source tree and \
absent from the payload can always be explained by one of these lists."
)]
async fn pack_create(
&self,
Parameters(req): Parameters<PackCreateReq>,
) -> Result<CallToolResult, McpError> {
let root = match req.root {
Some(r) => PathBuf::from(r),
None => {
let inner = self.state.read().await;
inner
.lds
.session()
.map_err(|_| no_session_error())?
.root()
.to_path_buf()
}
};
let mut opts =
lds_pack::CreateOptions::new(root, PathBuf::from(req.out), env!("CARGO_PKG_VERSION"));
opts.rules = pack_rules()?;
if let Some(level) = req.level {
opts.compression_level = level;
}
opts.dry_run = req.dry_run.unwrap_or(false);
let report = spawn_blocking(move || lds_pack::create(&opts))
.await
.map_err(|e| McpError::internal_error(format!("pack task failed: {e}"), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let m = &report.manifest;
json_result(&serde_json::json!({
"out_path": report.out_path.display().to_string(),
"dry_run": report.dry_run,
"compressed_bytes": report.compressed_bytes,
"stats": m.stats,
"skipped_secret": m.skipped_secret,
"skipped_cache": m.skipped_cache,
"skipped_noise": m.skipped_noise,
"symlinks": m.symlinks,
"worktrees": m.worktrees,
"worktree_of": m.worktree_of,
"no_link_report_applied": m.no_link_report_applied,
"kept_over_secret": m.kept_over_secret,
}))
}
#[tool(
description = "Restore a pack into a directory, rewriting each registered worktree's absolute \
gitdir wiring for the new location (a plain extract would leave both halves aimed at the source \
machine). A worktree living beside the project rather than inside it — what `git worktree add \
../name` produces — travels in its own pack, so restore the two beside each other and whichever \
lands second wires the pair up; order does not matter, and re-restoring with `force` repairs a pair \
that was left half-attached. Wiring writes both pointer files, so the counterpart is verified to be \
this worktree's other half before anything is written: a counterpart that is not on this machine is \
reported (`missing_worktrees`, or `missing_worktree_parent` when the pack is itself a worktree) \
rather than guessed at, and a counterpart path occupied by something else — an unrelated repository, \
or a same-named worktree of a different one — is reported in `conflicting_worktrees` and left \
untouched. Refuses an existing destination unless `force`; `force` overwrites and never deletes \
files the pack does not carry. A crafted archive is refused outright, and refused before the first \
byte is written: an entry or a manifest field that names a path outside the destination, a write \
routed through a symlink the archive planted, or an entry of a type a pack never contains (a device \
node, a fifo) aborts with an error instead of being written. A hard link entry is the one thing \
neither written nor refused — its target is a file on this machine that the pack does not carry, so \
it is listed in `hard_links_not_created` with the `ln` command that would create it, for the \
operator to run once they have looked at what the target is. Set `dry_run` to predict instead: how \
many entries would be written, which existing files would be replaced, which would remain \
untouched, which symlinks would dangle here, which worktree pointers would be rewritten, and which \
hard links would be left uncreated — an existing destination is not an error during a dry run, and \
an archive a restore would refuse is refused here too. Returns JSON {dest, dry_run, \
entries_written, destination_exists, would_overwrite, would_remain, rewritten_worktrees, \
missing_worktrees, conflicting_worktrees, missing_worktree_parent, dangling_symlinks, \
link_reports_suppressed, regenerable_caches, secrets_not_carried, hard_links_not_created, \
needs_attention} — \
`needs_attention` is the single flag to branch on, true when any of the report's follow-up lists is \
non-empty. `link_reports_suppressed` is informational and does not set it: it names the \
`no_link_report` rules in force when the pack was written, so an empty `dangling_symlinks` is not \
misread as \"every link here is fine\" when it means \"every link I was shown is fine\"."
)]
async fn pack_restore(
&self,
Parameters(req): Parameters<PackRestoreReq>,
) -> Result<CallToolResult, McpError> {
let opts = lds_pack::RestoreOptions {
archive: PathBuf::from(req.archive),
dest: PathBuf::from(req.into),
force: req.force.unwrap_or(false),
dry_run: req.dry_run.unwrap_or(false),
};
let report = spawn_blocking(move || lds_pack::restore(&opts))
.await
.map_err(|e| McpError::internal_error(format!("pack task failed: {e}"), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
json_result(&serde_json::json!({
"dest": report.dest.display().to_string(),
"dry_run": report.dry_run,
"entries_written": report.entries_written,
"destination_exists": report.destination_exists,
"would_overwrite": report.would_overwrite,
"would_remain": report.would_remain,
"rewritten_worktrees": report.rewritten_worktrees,
"missing_worktrees": report.missing_worktrees,
"conflicting_worktrees": report.conflicting_worktrees,
"missing_worktree_parent": report.missing_worktree_parent,
"dangling_symlinks": report.dangling_symlinks,
"link_reports_suppressed": report.link_reports_suppressed,
"regenerable_caches": report.regenerable_caches,
"secrets_not_carried": report.secrets_not_carried,
"hard_links_not_created": report.hard_links_not_created,
"needs_attention": report.needs_attention(),
}))
}
#[tool(
description = "Read a pack's manifest without unpacking it — the manifest is the archive's first \
entry, so this costs one small read rather than a full decompression. Reports provenance \
(source_root, created_at, lds_version), payload stats, and everything the pack deliberately left \
behind (secrets, caches) plus what needs attention on restore (symlinks, worktrees). \
`worktree_of` is set when the pack is itself a worktree checkout, and names the repository whose \
pack has to be restored beside it. `no_link_report_applied` names the rules that kept some of the \
project's links out of `symlinks`, and `kept_over_secret` any file a `keep` glob carried past the \
secret rules. Set `files` to also list every packed path, which does read the whole archive."
)]
async fn pack_inspect(
&self,
Parameters(req): Parameters<PackInspectReq>,
) -> Result<CallToolResult, McpError> {
let archive = PathBuf::from(req.archive);
let want_files = req.files.unwrap_or(false);
let (manifest, files) = spawn_blocking(move || {
let manifest = lds_pack::inspect(&archive)?;
let files = if want_files {
Some(lds_pack::list_payload_paths(&archive)?)
} else {
None
};
Ok::<_, lds_pack::PackError>((manifest, files))
})
.await
.map_err(|e| McpError::internal_error(format!("pack task failed: {e}"), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut out = serde_json::json!({
"project_name": manifest.project_name,
"created_at": manifest.created_at,
"source_root": manifest.source_root,
"lds_version": manifest.lds_version,
"format_version": manifest.format_version,
"stats": manifest.stats,
"skipped_secret": manifest.skipped_secret,
"skipped_cache": manifest.skipped_cache,
"skipped_noise": manifest.skipped_noise,
"symlinks": manifest.symlinks,
"worktrees": manifest.worktrees,
"worktree_of": manifest.worktree_of,
"no_link_report_applied": manifest.no_link_report_applied,
"kept_over_secret": manifest.kept_over_secret,
});
if let Some(files) = files {
out["files"] = serde_json::json!(files);
}
json_result(&out)
}
}
#[tool_handler]
impl ServerHandler for LdsServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.instructions = Some("local-develop-server: unified MCP for orch pipeline".into());
info.capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.enable_resources()
.build();
info
}
async fn list_tools(
&self,
request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let mut tools = Self::tool_router().list_all();
let plugins = self.list_plugin_tools().await.unwrap_or_else(|e| {
tracing::warn!(error = %e, "list_plugin_tools failed during list_tools");
Vec::new()
});
tools.extend(plugins);
tools.extend(self.list_export_tools().await);
{
let inner = self.state.read().await;
if let Some(outline) = inner.outline.as_ref() {
match outline
.list_tools_prefixed(request.clone(), context.clone())
.await
{
Ok(mut outline_tools) => tools.append(&mut outline_tools),
Err(e) => {
tracing::warn!(error = %e, "outline.list_tools_prefixed failed");
}
}
}
if let Some(journal) = inner.journal.as_ref() {
match journal.list_tools(request.clone(), context.clone()).await {
Ok(mut journal_tools) => tools.append(&mut journal_tools),
Err(e) => {
tracing::warn!(error = %e, "journal.list_tools failed");
}
}
}
}
tools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ListToolsResult {
tools,
meta: None,
next_cursor: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
if request.name != "session_start" {
maybe_auto_start_session(&self.state).await?;
}
if request.name != "session_start"
&& let Some(result) = self
.try_export_call(&request.name, request.arguments.as_ref())
.await?
{
return Ok(result);
}
if request.name != "session_start"
&& let Some(result) = self
.try_plugin_call(&request.name, request.arguments.as_ref())
.await?
{
return Ok(result);
}
if request
.name
.starts_with(lds_outline::module::OUTLINE_TOOL_PREFIX)
{
let inner = self.state.read().await;
if let Some(outline) = inner.outline.as_ref()
&& let Some(result) = outline.try_call(request.clone(), context.clone()).await?
{
return Ok(result);
}
}
if request
.name
.starts_with(lds_journal::module::JOURNAL_TOOL_PREFIX)
{
let inner = self.state.read().await;
if let Some(journal) = inner.journal.as_ref()
&& let Some(result) = journal.try_call(request.clone(), context.clone()).await?
{
return Ok(result);
}
}
let tcc = ToolCallContext::new(self, request, context);
Self::tool_router().call(tcc).await
}
async fn list_resources(
&self,
request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let inner = self.state.read().await;
let mut resources: Vec<Resource> = vec![
Annotated::new(
RawResource::new("lds://sessions", "sessions")
.with_description("Full multi-session ledger as JSON")
.with_mime_type("application/json"),
None,
),
Annotated::new(
RawResource::new("lds://sessions/doctor", "sessions/doctor")
.with_description("Doctor reports for every live session")
.with_mime_type("application/json"),
None,
),
Annotated::new(
RawResource::new("lds://docs/multi-session", "docs/multi-session")
.with_description("Multi-session ledger design + usage doc")
.with_mime_type("text/markdown"),
None,
),
Annotated::new(
RawResource::new("lds://docs/routing", "docs/routing")
.with_description(
"MCP routing + export: config.toml shape, tool contracts, usage examples",
)
.with_mime_type("text/markdown"),
None,
),
Annotated::new(
RawResource::new("lds://docs/pack", "docs/pack")
.with_description(
"pack: what travels, what is deliberately left behind, and what restore \
repairs — including worktrees split across two packs",
)
.with_mime_type("text/markdown"),
None,
),
];
for entry in inner.lds.list_sessions() {
let label = entry
.alias
.clone()
.unwrap_or_else(|| entry.session_id.clone());
resources.push(Annotated::new(
RawResource::new(
format!("lds://sessions/{label}"),
format!("session/{label}"),
)
.with_description(format!("Session {label} (root={})", entry.root.display()))
.with_mime_type("application/json"),
None,
));
resources.push(Annotated::new(
RawResource::new(
format!("lds://sessions/{label}/doctor"),
format!("session/{label}/doctor"),
)
.with_description(format!("Doctor report for session {label}"))
.with_mime_type("application/json"),
None,
));
}
if let Some(outline) = inner.outline.as_ref() {
match outline
.list_resources(request.clone(), context.clone())
.await
{
Ok(outline_resources) => resources.extend(outline_resources.resources),
Err(e) => tracing::warn!(error = %e, "outline.list_resources failed"),
}
}
if let Some(journal) = inner.journal.as_ref() {
match journal.list_resources(request, context).await {
Ok(journal_resources) => resources.extend(journal_resources.resources),
Err(e) => tracing::warn!(error = %e, "journal.list_resources failed"),
}
}
Ok(ListResourcesResult {
resources,
meta: None,
next_cursor: None,
})
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
let templates: Vec<ResourceTemplate> = vec![
Annotated::new(
RawResourceTemplate::new("lds://sessions/{key}", "session by key")
.with_description("Describe a session by id or alias")
.with_mime_type("application/json"),
None,
),
Annotated::new(
RawResourceTemplate::new("lds://sessions/{key}/doctor", "session doctor by key")
.with_description("Doctor report for a single session")
.with_mime_type("application/json"),
None,
),
];
Ok(ListResourceTemplatesResult {
resource_templates: templates,
meta: None,
next_cursor: None,
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
if request.uri.starts_with("outline://") {
let inner = self.state.read().await;
if let Some(outline) = inner.outline.as_ref()
&& let Some(result) = outline
.try_read_resource(request.clone(), context.clone())
.await?
{
return Ok(result);
}
}
if request.uri.starts_with("journal-mcp://") {
let inner = self.state.read().await;
if let Some(journal) = inner.journal.as_ref()
&& let Some(result) = journal.try_read_resource(request.clone(), context).await?
{
return Ok(result);
}
}
let uri = request.uri;
let inner = self.state.read().await;
let body = read_lds_resource(&uri, &inner.lds)?;
Ok(ReadResourceResult::new(vec![body]))
}
}
fn read_lds_resource(uri: &str, ledger: &LdsState) -> Result<ResourceContents, McpError> {
let path = uri
.strip_prefix("lds://")
.ok_or_else(|| McpError::invalid_params(format!("unknown URI scheme: {uri}"), None))?;
if path == "docs/multi-session" {
return Ok(ResourceContents::TextResourceContents {
uri: uri.to_string(),
mime_type: Some("text/markdown".into()),
text: MULTI_SESSION_DOC.into(),
meta: None,
});
}
if path == "docs/routing" {
return Ok(ResourceContents::TextResourceContents {
uri: uri.to_string(),
mime_type: Some("text/markdown".into()),
text: ROUTING_DOC.into(),
meta: None,
});
}
if path == "docs/pack" {
return Ok(ResourceContents::TextResourceContents {
uri: uri.to_string(),
mime_type: Some("text/markdown".into()),
text: PACK_DOC.into(),
meta: None,
});
}
if path == "sessions" {
let entries: Vec<serde_json::Value> = ledger
.list_sessions()
.into_iter()
.map(session_entry_to_json)
.collect();
let text = serde_json::to_string_pretty(&serde_json::json!({ "sessions": entries }))
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
return Ok(json_resource(uri, text));
}
if path == "sessions/doctor" {
let mut reports: Vec<serde_json::Value> = Vec::new();
for entry in ledger.list_sessions() {
let r = ledger
.doctor(&entry.session_id)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
reports.push(doctor_report_to_json(&r));
}
let text = serde_json::to_string_pretty(&serde_json::json!({ "reports": reports }))
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
return Ok(json_resource(uri, text));
}
if let Some(rest) = path.strip_prefix("sessions/") {
let (key, want_doctor) = match rest.strip_suffix("/doctor") {
Some(k) => (k, true),
None => (rest, false),
};
if key.is_empty() || key.contains('/') {
return Err(McpError::invalid_params(
format!("malformed session URI: {uri}"),
None,
));
}
if want_doctor {
let r = ledger
.doctor(key)
.map_err(|e| McpError::resource_not_found(e.to_string(), None))?;
let text = serde_json::to_string_pretty(&doctor_report_to_json(&r))
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
return Ok(json_resource(uri, text));
} else {
let entry = ledger
.describe(key)
.map_err(|e| McpError::resource_not_found(e.to_string(), None))?;
let text = serde_json::to_string_pretty(&session_entry_to_json(entry))
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
return Ok(json_resource(uri, text));
}
}
Err(McpError::resource_not_found(
format!("unknown lds resource: {uri}"),
None,
))
}
fn json_resource(uri: &str, text: String) -> ResourceContents {
ResourceContents::TextResourceContents {
uri: uri.to_string(),
mime_type: Some("application/json".into()),
text,
meta: None,
}
}
fn session_entry_to_json(e: lds_core::SessionEntry) -> serde_json::Value {
serde_json::json!({
"session_id": e.session_id,
"alias": e.alias,
"root": e.root.display().to_string(),
"created_at": e.created_at,
"last_used_at": e.last_used_at,
"is_default": e.is_default,
})
}
fn doctor_report_to_json(r: &lds_core::DoctorReport) -> serde_json::Value {
let checks: Vec<serde_json::Value> = r
.checks
.iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"status": c.status.as_str(),
"evidence": c.evidence,
})
})
.collect();
serde_json::json!({
"session_id": r.session_id,
"alias": r.alias,
"verdict": r.verdict.as_str(),
"checks": checks,
})
}
const MULTI_SESSION_DOC: &str = r#"# lds Multi-Session Ledger
lds tracks **multiple concurrent sessions** in an in-memory ledger so MainAI
and SubAgents (or fixture / worktree side-sessions) can coexist without one
silently overriding another's `root` binding.
## Addressing
Every session has two handles:
- `session_id` — opaque hash assigned at create time
- `alias` — optional, human-readable label (`worker-1`, `fixture-A`, ...)
Pass either to `session_describe` / `session_doctor` / `session_close` /
`session_alias_set`. The legacy `session_start` tool always replaces the
**default session** for backward-compatible tool calls that omit
`session_id`.
## Resources
- `lds://sessions` — full ledger as JSON
- `lds://sessions/doctor` — doctor reports for every session
- `lds://sessions/{key}` — describe one session (`key` = id or alias)
- `lds://sessions/{key}/doctor` — doctor report for one session
- `lds://docs/multi-session` — this doc
- `lds://docs/routing` — MCP routing + export design + usage
- `lds://docs/pack` — what a pack carries and what restore repairs
## Doctor checks (3-valued verdict: ok / warn / fail)
| check | what it verifies |
|-----------------------|-----------------------------------------------------|
| root-exists | the session root still exists on disk |
| git-bound | `.git` is present (git_* tools will work) |
| journal-db-writable | journal storage directory is writable |
| stale-lock | no leftover `.journal.db.lock` older than 1h |
| ownership-drift | no other session claims the same root |
| root-conflict | escalates to FAIL when ≥2 sessions share the root |
| ledger-leak | warns when a session has been idle > 6h |
## Patterns
- **MainAI + worker SubAgent** — MainAI keeps the default session; each
SubAgent calls `session_create` with its own root + alias.
- **Fixture / sandbox runs** — spawn an isolated session on a tempdir;
close it when done.
- **Observability sweep** — periodically read `lds://sessions/doctor` to
catch root drift, conflicts, and idle leakage.
"#;
const ROUTING_DOC: &str = r#"# lds MCP Routing + Export
lds proxies calls to **external MCP servers** via a session-bound gateway so
callers only need one tool surface (`mcp_call`) instead of loading every
upstream server's schema. Declared upstream tools can additionally be
**re-exported** to the caller's tool list with a prefixed name.
## Tools
- `mcp_call(uri, args)` — proxy a single call. `uri = "<route>://<tool>"`.
`args` must be a JSON object.
- `mcp_route_list` — enumerate registered routes for the active session.
- `mcp_route_register(name, command, args?, env?, timeout_secs?)` — add or
replace a route **in-memory only** (not persisted).
- `mcp_route_remove(name)` — remove a route and terminate its subprocess.
- `mcp_export_list` — enumerate currently materialized `[[export]]` tools.
- `mcp_export_refresh` — re-poll each declared export route's upstream
`list_tools`, then rebuild the exported tool set atomically.
## Reserved scheme
`lds://` is reserved. `mcp_call(uri="lds://…")` returns
`RouterError::SelfLoop` before any lookup.
## Config file (`config.toml`)
Routes and exports live in the shared `config.toml` (same file as
`[recipes]` / `[paths]`). Two locations are merged at `session_start`:
- User-global: `~/.config/lds/config.toml`
- Project-local: `<session_root>/config.toml` (overrides user by `name` /
`route`)
Both files are optional. Unknown top-level keys are ignored, so router
config coexists with existing `[recipes]` / `[paths]` sections without
schema conflict.
### `[[route]]`
```toml
[[route]]
name = "outline"
command = "outline-mcp"
args = ["--stdio"]
timeout_secs = 30 # default 30
env = { OUTLINE_HOME = "${LDS_SESSION_ROOT}/.outline" }
```
Fields:
- `name` (required) — the `<route>` component of `<route>://<tool>` URIs.
Must be unique per session.
- `command` (required) — subprocess command, resolved via `PATH`.
- `args` (default `[]`) — CLI arguments.
- `env` (default `{}`) — extra env vars for the subprocess.
- `timeout_secs` (default 30) — per-call timeout (applies to both
`call_tool` and `list_tools`).
`${LDS_SESSION_ROOT}` is expanded in `args` and `env` values only.
`name` and `command` are never expanded — an unexpanded literal in
`command` will fail fast at `Command::new` time.
Subprocess spawn is **lazy**: no subprocess is started until the first
`mcp_call` (or `mcp_export_refresh`) touches the route.
### `[[export]]`
```toml
[[export]]
route = "outline"
tools = ["search_notes", "get_note"]
# prefix = "outline_" # default: "<route>_"
```
Fields:
- `route` (required) — must match a `[[route]]` `name` in the same
session's config.
- `tools` (required) — the upstream tools' exact names, as an array.
- `prefix` (optional) — override the default `<route>_` prefix.
Declared exports appear in the caller's `list_tools` as
`<prefix><tool>` (e.g. `outline_search_notes`), with the upstream tool's
schema copied verbatim. Undeclared upstream tools remain accessible only
via generic `mcp_call`.
**Limits**:
- Total exported tool count is capped at 16 by default. Exceeding it
fails `session_start` with `RouterError::ExportLimitExceeded`.
- Name collisions between exports (same prefixed name) fail
`session_start` with `RouterError::ExportCollision`.
- Collisions with lds's own static tool names (e.g. `git_status`,
`session_start`) also fail — the router keeps the built-in and rejects
the export.
## Usage examples
### Proxy a single call
```
mcp_call(uri="outline://search_notes", args={"query": "changelog"})
→ JSON returned verbatim from outline-mcp
```
### Auto-register at session start (persistent)
Add `[[route]]` blocks to `~/.config/lds/config.toml` — they are wired
automatically the next time `session_start` runs (or on the first
tool call that triggers auto-start).
### Runtime-only route (not persisted)
```
mcp_route_register(name="scratch", command="my-experimental-mcp")
mcp_call(uri="scratch://ping")
mcp_route_remove(name="scratch")
```
### Refresh export schemas after an upstream restart
```
mcp_export_refresh # atomic rebuild; export snapshot swaps in one step
mcp_export_list # confirm the new tool surface
```
## Failure modes
| Error | When |
|-----------------------------|------------------------------------------------------------|
| `RouterError::SelfLoop` | `mcp_call(uri="lds://…")` — reserved scheme rejected |
| `RouterError::InvalidUri` | URI does not match `<route>://<tool>` |
| `RouterError::RouteNotFound`| No route registered for the URI's `<route>` |
| `RouterError::Timeout` | Upstream `call_tool` or `list_tools` exceeded `timeout_secs` |
| `RouterError::Spawn` | Subprocess spawn failed (typically `command` not on `PATH`) |
| `RouterError::Upstream` | Transparent upstream MCP-level error (message forwarded) |
| `RouterError::Config` | `config.toml` `[[route]]` / `[[export]]` parse error |
| `RouterError::ExportLimitExceeded` | `[[export]]` total exceeds 16 |
| `RouterError::ExportCollision` | Two exports produce the same prefixed tool name |
## Concurrency
- `mcp_call` acquires the router's read lock only for HashMap lookup +
`Arc<RouteClient>` clone; the upstream `.await` runs lock-free.
- Calls to the *same* route serialize on a per-`RouteClient` mutex (one
stdio stream per subprocess). Calls to *different* routes are fully
concurrent.
- `session_start` / auto-start hold the session write lock only for the
synchronous local module wiring. Network I/O (`list_tools` for each
declared export) runs after the lock is dropped; the write lock is
reacquired only long enough to assign the built router + export
registry.
## See also
- Tool doc: use MCP `tools/list` — each `mcp_*` tool carries its own
`description` + `annotations` (idempotent / destructive hints).
- Resource: `lds://docs/multi-session` — session model this router
builds on top of.
"#;
const PACK_DOC: &str = r#"# lds pack
`pack_create` / `pack_restore` / `pack_inspect` move a project **as it exists
on this machine** — not as it exists in the remote. The distinction is the
whole point: a clone gets you the tracked history, a pack gets you the working
copy you were actually in the middle of.
## What travels
- **`.git` wholesale.** Copied as a directory, not converted to a `git bundle`.
A bundle is assembled from an enumerated set of refs, so stashes, the reflog,
local-only branches and unreferenced objects would be dropped. Those are
usually the parts that exist nowhere else.
- **Untracked local state** — `workspace/`, agent and editor dotfiles, sandbox
snapshots, journal databases. The scan deliberately ignores `.gitignore`:
consulting it would drop exactly the material the pack exists to preserve.
- **Symlinks, as links.** Never followed — following one would drag an
unrelated tree, and whatever it contains, into the archive.
Every symlink is reported, because a link is something to act on: it breaks when
the project lands somewhere else. The exception is a directory that is *meant*
to be links — a shared dotfile tree deployed the same way on every machine you
use — where you already know and the entries only hide the links that do need
attention. Name such a path in `[pack] no_link_report` and its links are packed
without being reported.
There is no default, and the suppression is never silent: every rule that
actually left something out comes back in `no_link_report_applied`, so an empty
`symlinks` list can always be told apart from a filtered one. The list itself
stays complete and one record per link — redirect it to a file and process it.
## Scoping a `[pack]` rule
Globs in `[pack]` are scoped the way `.gitignore` scopes them: one with no `/`
matches the file name at any depth, one containing `/` is anchored to that path
relative to the project root.
Reaching the whole tree is right when naming a *kind* of file and is the hazard
when naming *one* file. `keep = ["*.pem"]`, written to carry a sample key,
carries every private key in the project — and `keep` is the one list that can
put a secret-matching file into the archive. `keep = ["docs/samples/*.pem"]`
carries the sample. The same applies to `cache_dirs`: `dist` drops every `dist/`
including a hand-written one, `frontend/dist` drops the build output you meant.
## What does not
- **Secrets** (`.env`, `*.pem`, `credentials.toml`, ...) are reported in
`skipped_secret` and never packed. Moving credentials is the operator's own
business, and a pack is a file that gets copied around.
- **Caches** (`target/`, `node_modules/`, ...) are dropped and listed in
`skipped_cache`; they are regenerable by definition. One record stands in for
a whole subtree, so each carries the file count and byte size that went with
it — a `cache_dirs` entry pointed at hand-written source reads as `dist → 412
files, 2.1 MB` next to `target → 38104 files, 4.2 GB`, where the path alone
would have read the same either way. Each also lists any credential-looking
file found inside: pruning happens before the secret pass, so a
`node_modules/.npmrc` is neither packed nor otherwise reported, and you would
never learn a token was sitting there.
- **OS debris** (`.DS_Store`, `Thumbs.db`) is dropped and listed in
`skipped_noise`. There is nothing to do about it; it is recorded because a
path that is in the source tree and not in the payload should always be
explainable by one of these lists.
Both lists are extensible via `[pack]` in `~/.config/lds/config.toml`.
## Restoring
`force` means **overwrite, not wipe**: entries in the pack replace their
counterparts in the destination, and files already there that the pack does not
carry are left alone. Nothing is deleted on the operator's behalf. `dry_run`
answers what a restore would do — which files would be replaced, which would
survive, which symlinks would land dangling *here* — before the first byte is
written, and does not require `force` even over an existing destination.
## Worktrees
A registered worktree is wired with two absolute paths: the repository's
`.git/worktrees/<name>/gitdir` names the checkout's `.git` file, and that file
names the admin directory back. A plain `tar` extract leaves both aimed at the
machine the pack came from, so restore rewrites them.
Where the checkout lives decides how much one pack can fix:
- **Inside the root** (`.worktrees/<name>`, or anywhere else under it) —
both halves travel in the same pack, and restore wires them up on its own.
- **Beside the root** — what `git worktree add ../name` produces. The two
halves belong to two different projects, so they travel in two packs and
neither can complete the wiring alone.
For the second shape, pack each side, restore them beside each other, and
whichever lands second wires the pair up. Order does not matter, and
re-restoring either side with `force` repairs a pair left half-attached. The
counterpart is looked for at the same offset from the new root that it had from
the old one — restore a set of sibling projects together and it is found.
Nothing is guessed at, and nothing that is not this worktree's other half is
written to. A counterpart that is not on this machine is reported. A candidate
path occupied by something else — an independent repository (its `.git` is a
directory), or a same-named worktree whose pointer names a *different*
repository — is reported in `conflicting_worktrees` and left untouched:
wiring writes both pointer files, and one of them would overwrite the
occupant's, destroying one project to repair another.
## What restore refuses, and the one thing it defers
A pack is normally written by this crate, but an archive is an untrusted input
the moment it arrives from elsewhere. Everything it says about where things go
— entry names *and* manifest fields, which are the same claim made twice — is
checked before the first byte is written, so an archive that names a path
outside the destination is refused with the destination still empty. So is a
write routed through a symlink the archive planted, and so is an entry of a type
a pack never contains: a device node, a fifo, an unrecognized type that `tar`
would otherwise write out as an ordinary file.
A hard link is neither written nor refused. It names a file the pack does not
carry, so the target is whatever happens to be at that path on the machine
restoring it — creating the link would publish that file into the restored tree
under a name the archive chose. It is listed in `hard_links_not_created`, with
the `ln` command that would create it. Look at the target, then run the command
if it is what you expect.
## Fields worth acting on
| field | on | meaning |
|---|---|---|
| `worktrees[]` | inspect / create | registered worktrees; `included: false` means the checkout is packed separately, and `source_path` says where it was |
| `worktree_of` | inspect / create | this pack *is* a worktree checkout; names the repository whose pack has to land beside it |
| `rewritten_worktrees` | restore | pairs wired up for the new location |
| `missing_worktrees` | restore | registered checkouts not on this machine — restore their packs and the wiring completes |
| `conflicting_worktrees` | restore | the counterpart's place is occupied by something that is not this worktree's other half; nothing was written there |
| `missing_worktree_parent` | restore | this pack is a worktree and its repository is not beside it, so the restored tree has no repository at all |
| `no_link_report_applied` | inspect / create | `no_link_report` rules that actually kept links out of `symlinks`; absent means nothing was filtered |
| `kept_over_secret` | inspect / create | files a `keep` glob carried past a secret rule — the only way a secret-matching file is inside the archive |
| `skipped_cache[]` | inspect / create / restore | dropped caches with `file_count` / `total_bytes`; figures that do not look like a build tree's mean the rule caught the wrong directory |
| `skipped_cache[].secrets` | inspect / create / restore | credential-looking files that were inside a dropped cache — not packed, but worth knowing are on your disk |
| `dangling_symlinks` | restore | restored links whose targets do not exist here |
| `link_reports_suppressed` | restore | the pack's `no_link_report` rules; links there were restored but never checked for dangling |
| `secrets_not_carried` | restore | move these out of band |
| `hard_links_not_created` | restore | links the archive named and restore did not create; each carries the `ln` command that would, once you have looked at the target |
`needs_attention` is true when any of the above needs follow-up.
## See also
- Resource: `lds://docs/multi-session` — session model; `pack_create` defaults
its `root` to the active session root.
"#;
fn plugin_to_tool(plugin: lds_recipe::PluginRecipe) -> Tool {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for p in &plugin.parameters {
let mut prop = serde_json::Map::new();
prop.insert(
"type".to_string(),
serde_json::Value::String("string".to_string()),
);
properties.insert(p.name.clone(), serde_json::Value::Object(prop));
if p.default.is_none() {
required.push(serde_json::Value::String(p.name.clone()));
}
}
let mut schema = serde_json::Map::new();
schema.insert(
"type".to_string(),
serde_json::Value::String("object".to_string()),
);
schema.insert(
"properties".to_string(),
serde_json::Value::Object(properties),
);
if !required.is_empty() {
schema.insert("required".to_string(), serde_json::Value::Array(required));
}
let description = if plugin.description.is_empty() {
format!("Plugin recipe: {}", plugin.name)
} else {
plugin.description
};
Tool::new(plugin.name, description, Arc::new(schema))
}
async fn serve_mcp() -> Result<()> {
tracing::info!("lds v{}", env!("CARGO_PKG_VERSION"));
let server = LdsServer::new();
maybe_auto_start_session(&server.state).await?;
let service = server.serve(rmcp::transport::io::stdio()).await?;
service.waiting().await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
if std::env::args_os().count() <= 1 {
serve_mcp().await
} else {
cli::run()
}
}
#[cfg(test)]
mod tests {
use super::*;
use lds_core::config::{Config, Paths, Recipes};
fn resource_text(body: ResourceContents) -> String {
match body {
ResourceContents::TextResourceContents { text, .. } => text,
other => panic!("expected a text resource, got {other:?}"),
}
}
#[test]
fn test_doc_resources_resolve() {
let ledger = LdsState::new();
for (uri, expected) in [
("lds://docs/multi-session", MULTI_SESSION_DOC),
("lds://docs/routing", ROUTING_DOC),
("lds://docs/pack", PACK_DOC),
] {
let body = read_lds_resource(uri, &ledger)
.unwrap_or_else(|e| panic!("{uri} must resolve, got {e}"));
assert_eq!(resource_text(body), expected, "{uri} served the wrong doc");
}
}
#[test]
fn test_pack_doc_covers_the_worktree_report_fields() {
for field in [
"worktree_of",
"rewritten_worktrees",
"missing_worktrees",
"missing_worktree_parent",
] {
assert!(
PACK_DOC.contains(field),
"pack doc must explain `{field}`; a caller cannot act on a field it never sees"
);
}
}
#[test]
fn test_unknown_resource_is_rejected() {
let ledger = LdsState::new();
assert!(read_lds_resource("lds://docs/nope", &ledger).is_err());
assert!(read_lds_resource("https://example.com", &ledger).is_err());
}
#[test]
fn test_resolve_startup_global_dirs_priority_order() {
let cfg = Config {
recipes: Recipes {
dirs: vec![PathBuf::from("/config/dir1"), PathBuf::from("/config/dir2")],
},
paths: Paths {
global_justfile: Some(PathBuf::from("/custom/justfile")),
},
..Default::default()
};
#[cfg(unix)]
let env_val = OsString::from("/env/dir1:/env/dir2");
#[cfg(windows)]
let env_val = OsString::from("/env/dir1;/env/dir2");
let dirs = resolve_startup_global_dirs(cfg, Some(env_val));
assert_eq!(dirs.len(), 5, "expected 5 entries, got: {dirs:?}");
assert_eq!(dirs[0], PathBuf::from("/custom/justfile"));
assert_eq!(dirs[1], PathBuf::from("/config/dir1"));
assert_eq!(dirs[2], PathBuf::from("/config/dir2"));
assert_eq!(dirs[3], PathBuf::from("/env/dir1"));
assert_eq!(dirs[4], PathBuf::from("/env/dir2"));
}
#[test]
fn test_resolve_startup_global_dirs_all_empty() {
let dirs = resolve_startup_global_dirs(Config::default(), None);
assert!(dirs.is_empty(), "expected empty dirs, got: {dirs:?}");
}
#[test]
fn test_resolve_startup_global_dirs_env_only() {
#[cfg(unix)]
let env_val = OsString::from("/env/only");
#[cfg(windows)]
let env_val = OsString::from("/env/only");
let dirs = resolve_startup_global_dirs(Config::default(), Some(env_val));
assert_eq!(dirs, vec![PathBuf::from("/env/only")]);
}
#[test]
fn test_resolve_startup_global_dirs_config_only() {
let cfg = Config {
recipes: Recipes {
dirs: vec![PathBuf::from("/config/only")],
},
paths: Paths {
global_justfile: None,
},
..Default::default()
};
let dirs = resolve_startup_global_dirs(cfg, None);
assert_eq!(dirs, vec![PathBuf::from("/config/only")]);
}
#[test]
fn parse_since_string_accepts_epoch_integer() {
assert_eq!(parse_since_string("1751932800").unwrap(), 1751932800);
assert_eq!(parse_since_string("0").unwrap(), 0);
assert_eq!(parse_since_string(" 42 ").unwrap(), 42);
}
#[test]
fn parse_since_string_accepts_rfc3339_utc() {
let epoch = parse_since_string("2026-07-14T00:00:00Z").unwrap();
assert_eq!(epoch, 1_783_987_200);
}
#[test]
fn parse_since_string_accepts_rfc3339_with_offset() {
let with_offset = parse_since_string("2026-07-14T09:00:00+09:00").unwrap();
let utc = parse_since_string("2026-07-14T00:00:00Z").unwrap();
assert_eq!(with_offset, utc);
}
#[test]
fn parse_since_string_rejects_garbage() {
assert!(parse_since_string("yesterday").is_err());
assert!(parse_since_string("2026-99-99T00:00:00Z").is_err());
}
}