use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::tool::ToolCallContext;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
CallToolRequestParams, CallToolResult, Content, ErrorData as McpError, ListToolsResult,
PaginatedRequestParams, ServerCapabilities, ServerInfo,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{ServerHandler, ServiceExt, tool, tool_router};
use serde::Deserialize;
use std::path::PathBuf;
use crate::cli::global::GlobalFlags;
use crate::exit;
use crate::plan::{Operation, Plan};
fn default_strict_true() -> bool {
true
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocSetParams {
pub path: String,
pub selector: String,
pub value: serde_json::Value,
#[serde(default = "default_strict_true")]
pub strict: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocDeleteParams {
pub path: String,
pub selector: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocMergeParams {
pub path: String,
pub value: serde_json::Value,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocArrayParams {
pub path: String,
pub selector: String,
pub value: serde_json::Value,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocEnsureParams {
pub path: String,
pub selector: String,
pub value: serde_json::Value,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocUpdateParams {
pub path: String,
pub selector: String,
pub value: serde_json::Value,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocMoveParams {
pub path: String,
pub from: String,
pub to: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocDeleteWhereParams {
pub path: String,
pub selector: String,
pub predicate: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct ReplaceParams {
pub path: String,
pub from: String,
pub to: Option<String>,
pub insert_before: Option<String>,
pub insert_after: Option<String>,
#[serde(default)]
pub regex: bool,
pub nth: Option<usize>,
#[serde(default)]
pub case_insensitive: bool,
#[serde(default)]
pub multiline: bool,
#[serde(default)]
pub if_exists: bool,
#[serde(default)]
pub whole_line: bool,
pub range: Option<String>,
#[serde(default)]
pub word_boundary: bool,
pub before_context: Option<String>,
pub after_context: Option<String>,
#[serde(default = "default_strict_true")]
pub strict: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocGetParams {
pub path: String,
pub selector: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct ReadFileParams {
pub path: String,
pub lines: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct MdLintAgentsParams {
pub path: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct MdUpsertBulletParams {
pub path: String,
pub heading: String,
pub bullet: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct MdTableAppendParams {
pub path: String,
pub heading: String,
pub row: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct MdReplaceSectionParams {
pub path: String,
pub heading: String,
pub content: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct MdInsertParams {
pub path: String,
pub heading: String,
pub content: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct MdMoveSectionParams {
pub path: String,
pub heading: String,
#[serde(default)]
pub to: Option<String>,
#[serde(default)]
pub before: Option<String>,
#[serde(default)]
pub after: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct TidyParams {
pub path: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct FileRenameParams {
pub from: String,
pub to: String,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct AppendFileParams {
pub path: String,
pub content: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct CreateFileParams {
pub path: String,
pub content: String,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DeleteFileParams {
pub path: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct PatchParams {
pub diff: String,
#[serde(default)]
pub on_stale: crate::ops::patch::OnStale,
#[serde(default)]
pub allow_conflicts: bool,
#[serde(default = "default_strict_true")]
pub strict: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct SearchParams {
pub pattern: String,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub literal: bool,
#[serde(default)]
pub case_insensitive: bool,
pub context: Option<usize>,
pub before_context: Option<usize>,
pub after_context: Option<usize>,
#[serde(default)]
pub files_with_matches: bool,
#[serde(default)]
pub count: bool,
#[serde(default)]
pub multiline: bool,
#[serde(default)]
pub invert_match: bool,
pub assert_count: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocQueryParams {
pub action: String,
pub path: String,
pub selector: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct DocDiffParams {
pub file_a: String,
pub file_b: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct BatchReplaceParams {
pub files: Vec<String>,
pub from: String,
pub to: String,
#[serde(default)]
pub regex: bool,
#[serde(default)]
pub case_insensitive: bool,
#[serde(default)]
pub multiline: bool,
#[serde(default)]
pub word_boundary: bool,
#[serde(default = "default_strict_true")]
pub strict: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub(crate) struct BatchTidyParams {
pub files: Vec<String>,
#[serde(default = "default_strict_true")]
pub strict: bool,
}
const MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024;
const MAX_PARAM_BYTES: usize = 1024 * 1024;
const MAX_BATCH_FILES: usize = 1000;
const MAX_JSON_DEPTH: usize = 64;
fn validate_size(field: &str, value: &str, max_bytes: usize) -> Result<(), McpError> {
if value.len() > max_bytes {
return Err(McpError::invalid_params(
format!(
"{field} exceeds maximum size ({} bytes, limit {max_bytes})",
value.len(),
),
None,
));
}
Ok(())
}
fn validate_content_size(field: &str, value: &str) -> Result<(), McpError> {
validate_size(field, value, MAX_CONTENT_BYTES)
}
fn validate_param_size(field: &str, value: &str) -> Result<(), McpError> {
validate_size(field, value, MAX_PARAM_BYTES)
}
fn validate_json_depth(field: &str, value: &serde_json::Value) -> Result<(), McpError> {
fn measure(v: &serde_json::Value) -> usize {
match v {
serde_json::Value::Array(arr) => 1 + arr.iter().map(measure).max().unwrap_or(0),
serde_json::Value::Object(obj) => 1 + obj.values().map(measure).max().unwrap_or(0),
_ => 0,
}
}
let depth = measure(value);
if depth > MAX_JSON_DEPTH {
return Err(McpError::invalid_params(
format!("{field} exceeds maximum nesting depth ({depth}, limit {MAX_JSON_DEPTH})"),
None,
));
}
Ok(())
}
fn validate_batch_size(field: &str, count: usize) -> Result<(), McpError> {
if count > MAX_BATCH_FILES {
return Err(McpError::invalid_params(
format!("{field} exceeds maximum batch size ({count}, limit {MAX_BATCH_FILES})"),
None,
));
}
Ok(())
}
#[cfg(test)]
fn validate_operation_paths(
operations: &[Operation],
cwd: &std::path::Path,
) -> Result<(), McpError> {
let guard = crate::containment::PathGuard::new(
cwd.to_path_buf(),
crate::containment::AbsolutePathPolicy::Reject,
)
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
for op in operations {
let paths: Vec<&str> = match op {
Operation::DocSet { path, .. }
| Operation::DocDelete { path, .. }
| Operation::DocAppend { path, .. }
| Operation::DocPrepend { path, .. }
| Operation::DocEnsure { path, .. }
| Operation::DocDeleteWhere { path, .. }
| Operation::DocUpdate { path, .. }
| Operation::DocMove { path, .. }
| Operation::MdReplaceSection { path, .. }
| Operation::MdInsertAfterHeading { path, .. }
| Operation::MdInsertBeforeHeading { path, .. }
| Operation::MdUpsertBullet { path, .. }
| Operation::MdTableAppend { path, .. }
| Operation::MdDedupeHeadings { path, .. }
| Operation::MdLintAgents { path, .. }
| Operation::TidyFix { path, .. }
| Operation::FileAppend { path, .. }
| Operation::FileCreate { path, .. }
| Operation::FileDelete { path, .. }
| Operation::Read { path, .. }
| Operation::Search { path, .. } => vec![path.as_str()],
#[cfg(feature = "ast")]
Operation::AstRename { path, .. } | Operation::AstReplace { path, .. } => {
vec![path.as_str()]
}
Operation::MdMoveSection { path, to, .. } => {
let mut p = vec![path.as_str()];
if let Some(to) = to {
p.push(to.as_str());
}
p
}
Operation::DocMerge { path, .. } => vec![path.as_str()],
Operation::Replace { path, glob, .. } => {
let mut p = Vec::new();
if let Some(path) = path {
p.push(path.as_str());
}
if let Some(glob) = glob {
p.push(glob.as_str());
}
p
}
Operation::FileRename { from, to, .. } => vec![from.as_str(), to.as_str()],
Operation::PatchApply { diff, .. } => {
let patch_files = crate::ops::patch::parse_patch(diff).map_err(|e| {
McpError::invalid_params(
format!("failed to parse diff for path validation: {e}"),
None,
)
})?;
for pf in &patch_files {
guard
.check_path(&pf.path)
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
}
vec![]
}
};
for path in paths {
guard
.check_path(path)
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct PatchloomService {
tool_router: ToolRouter<Self>,
path_guard: crate::containment::PathGuard,
call_log: Option<PathBuf>,
}
impl PatchloomService {
pub fn new(cwd: PathBuf, log_flag: Option<String>) -> anyhow::Result<Self> {
let path_guard = crate::containment::PathGuard::new(
cwd.clone(),
crate::containment::AbsolutePathPolicy::Reject,
)
.map_err(|e| anyhow::anyhow!("failed to initialize path guard: {e}"))?;
let call_log = log_flag
.map(PathBuf::from)
.or_else(|| std::env::var_os("PATCHLOOM_MCP_LOG").map(PathBuf::from));
Ok(Self {
tool_router: Self::tool_router(),
path_guard,
call_log,
})
}
fn check_path(&self, path: &str) -> Result<(), McpError> {
self.path_guard
.check_path(path)
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
Ok(())
}
fn cwd(&self) -> &std::path::Path {
self.path_guard.root()
}
fn log_tool_call(
&self,
tool: &str,
duration_ms: u64,
result: &Result<CallToolResult, McpError>,
) {
let Some(ref log_path) = self.call_log else {
return;
};
let (ok, error) = match result {
Ok(r) => (!r.is_error.unwrap_or(false), None),
Err(e) => (false, Some(format!("{e}"))),
};
let ts = {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
d.as_millis()
};
let mut entry = serde_json::json!({
"ts": ts,
"tool": tool,
"duration_ms": duration_ms,
"ok": ok,
});
if let Some(err_msg) = error {
entry["error"] = serde_json::Value::String(err_msg);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
{
use std::io::Write;
let _ = writeln!(f, "{entry}");
}
}
}
fn execute_plan_validated(plan: Plan, cwd: &std::path::Path) -> Result<CallToolResult, McpError> {
let (code, json) = crate::cmd::tx::execute_plan_direct(plan, cwd)
.map_err(|e| McpError::internal_error(format!("plan execution failed: {e}"), None))?;
exit_code_to_result(code, &json, "Operation completed successfully.")
}
fn exit_code_to_result(
code: u8,
output: &str,
success_fallback: &str,
) -> Result<CallToolResult, McpError> {
if code == exit::SUCCESS {
let msg = if output.trim().is_empty() {
success_fallback.to_string()
} else {
output.trim().to_string()
};
Ok(CallToolResult::success(vec![Content::text(msg)]))
} else {
let msg = if output.trim().is_empty() {
format!("Operation failed with exit code {code}.")
} else {
output.trim().to_string()
};
Ok(CallToolResult::error(vec![Content::text(msg)]))
}
}
fn doc_readonly(action: &crate::cmd::doc::DocAction) -> Result<CallToolResult, McpError> {
let (output, code) =
crate::cmd::doc::execute_with_mode(action, crate::cmd::doc::OutputMode::Json)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
exit_code_to_result(code, &output, "No results.")
}
fn make_plan(operations: Vec<Operation>) -> Plan {
make_plan_strict(operations, None)
}
fn make_plan_strict(operations: Vec<Operation>, strict: Option<bool>) -> Plan {
Plan {
version: crate::plan::SCHEMA_VERSION.to_string(),
cwd: None,
write_policy: None,
strict,
operations,
format: None,
validate: None,
}
}
#[tool_router]
impl PatchloomService {
#[tool(
description = "Set a value in a JSON, YAML, or TOML file. Parser-backed, preserves comments. Use dot notation for nested paths. Example: {\"path\": \"package.json\", \"selector\": \"version\", \"value\": \"2.0.0\"}"
)]
async fn doc_set(
&self,
Parameters(p): Parameters<DocSetParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
validate_json_depth("value", &p.value)?;
execute_plan_validated(
make_plan_strict(
vec![Operation::DocSet {
path: p.path,
selector: p.selector,
value: p.value,
}],
Some(p.strict),
),
self.cwd(),
)
}
#[tool(
description = "Delete a value from a JSON, YAML, or TOML file. Example: {\"path\": \"package.json\", \"selector\": \"scripts.test\"}"
)]
async fn doc_delete(
&self,
Parameters(p): Parameters<DocDeleteParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
execute_plan_validated(
make_plan(vec![Operation::DocDelete {
path: p.path,
selector: p.selector,
}]),
self.cwd(),
)
}
#[tool(
description = "Deep-merge an object into a JSON, YAML, or TOML document. Example: {\"path\": \"config.yaml\", \"value\": {\"server\": {\"port\": 8080}}}"
)]
async fn doc_merge(
&self,
Parameters(p): Parameters<DocMergeParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_json_depth("value", &p.value)?;
execute_plan_validated(
make_plan(vec![Operation::DocMerge {
path: p.path,
value: p.value,
}]),
self.cwd(),
)
}
#[tool(
description = "Append a value to an array in a JSON, YAML, or TOML file. Example: {\"path\": \"package.json\", \"selector\": \"dependencies\", \"value\": \"new-pkg\"}"
)]
async fn doc_append(
&self,
Parameters(p): Parameters<DocArrayParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
validate_json_depth("value", &p.value)?;
execute_plan_validated(
make_plan(vec![Operation::DocAppend {
path: p.path,
selector: p.selector,
value: p.value,
}]),
self.cwd(),
)
}
#[tool(
description = "Prepend a value to an array in a JSON, YAML, or TOML file. Inserts at position 0."
)]
async fn doc_prepend(
&self,
Parameters(p): Parameters<DocArrayParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
validate_json_depth("value", &p.value)?;
execute_plan_validated(
make_plan(vec![Operation::DocPrepend {
path: p.path,
selector: p.selector,
value: p.value,
}]),
self.cwd(),
)
}
#[tool(
description = "Set a value in JSON/YAML/TOML only if it does not already exist. Idempotent: no-op if present. Example: {\"path\": \"config.json\", \"selector\": \"debug\", \"value\": false}"
)]
async fn doc_ensure(
&self,
Parameters(p): Parameters<DocEnsureParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
validate_json_depth("value", &p.value)?;
execute_plan_validated(
make_plan(vec![Operation::DocEnsure {
path: p.path,
selector: p.selector,
value: p.value,
}]),
self.cwd(),
)
}
#[tool(
description = "Remove array items matching a predicate from JSON/YAML/TOML. Example: {\"path\": \"config.yaml\", \"selector\": \"users\", \"predicate\": \"role=admin\"}"
)]
async fn doc_delete_where(
&self,
Parameters(p): Parameters<DocDeleteWhereParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
validate_param_size("predicate", &p.predicate)?;
execute_plan_validated(
make_plan(vec![Operation::DocDeleteWhere {
path: p.path,
selector: p.selector,
predicate: p.predicate,
}]),
self.cwd(),
)
}
#[tool(
description = "Update all items matching a wildcard selector in a JSON, YAML, or TOML file. Example: {\"path\": \"config.yaml\", \"selector\": \"servers[*].port\", \"value\": 8080}"
)]
async fn doc_update(
&self,
Parameters(p): Parameters<DocUpdateParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
validate_json_depth("value", &p.value)?;
execute_plan_validated(
make_plan(vec![Operation::DocUpdate {
path: p.path,
selector: p.selector,
value: p.value,
}]),
self.cwd(),
)
}
#[tool(
description = "Move/rename a key in a JSON, YAML, or TOML file. Example: {\"path\": \"config.json\", \"from\": \"old_name\", \"to\": \"new_name\"}"
)]
async fn doc_move(
&self,
Parameters(p): Parameters<DocMoveParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("from", &p.from)?;
validate_param_size("to", &p.to)?;
execute_plan_validated(
make_plan(vec![Operation::DocMove {
path: p.path,
from: p.from,
to: p.to,
}]),
self.cwd(),
)
}
#[tool(
description = "Read a value from a JSON, YAML, or TOML file by selector. Example: {\"path\": \"package.json\", \"selector\": \"version\"}"
)]
async fn doc_get(
&self,
Parameters(p): Parameters<DocGetParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("selector", &p.selector)?;
let abs = self.cwd().join(&p.path);
let action = crate::cmd::doc::DocAction::Get {
file: abs.to_string_lossy().into_owned(),
selector: p.selector,
};
doc_readonly(&action)
}
#[tool(
description = "Query a JSON, YAML, or TOML file. Actions: \"has\" (check if selector exists, returns true/false), \"keys\" (list object keys at selector), \"len\" (count items at selector), \"select\" (filter array by predicate selector), \"flatten\" (list all leaf paths and values). Example: {\"action\": \"has\", \"path\": \"config.json\", \"selector\": \"database.host\"}"
)]
async fn doc_query(
&self,
Parameters(p): Parameters<DocQueryParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
if let Some(ref sel) = p.selector {
validate_param_size("selector", sel)?;
}
let abs = self.cwd().join(&p.path);
let file = abs.to_string_lossy().into_owned();
let action = match p.action.as_str() {
"has" => {
let selector = p.selector.ok_or_else(|| {
McpError::invalid_params("'has' action requires a selector".to_string(), None)
})?;
crate::cmd::doc::DocAction::Has { file, selector }
}
"keys" => {
let selector = p.selector.ok_or_else(|| {
McpError::invalid_params("'keys' action requires a selector".to_string(), None)
})?;
crate::cmd::doc::DocAction::Keys { file, selector }
}
"len" => {
let selector = p.selector.ok_or_else(|| {
McpError::invalid_params("'len' action requires a selector".to_string(), None)
})?;
crate::cmd::doc::DocAction::Len { file, selector }
}
"select" => {
let selector = p.selector.ok_or_else(|| {
McpError::invalid_params(
"'select' action requires a selector".to_string(),
None,
)
})?;
crate::cmd::doc::DocAction::Select { file, selector }
}
"flatten" => crate::cmd::doc::DocAction::Flatten { file },
other => {
return Err(McpError::invalid_params(
format!(
"unknown action '{other}'; valid actions: has, keys, len, select, flatten"
),
None,
));
}
};
doc_readonly(&action)
}
#[tool(
description = "Compare two structured files (JSON, YAML, or TOML) and show differences. Example: {\"file_a\": \"old.json\", \"file_b\": \"new.json\"}"
)]
async fn doc_diff(
&self,
Parameters(p): Parameters<DocDiffParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.file_a)?;
self.check_path(&p.file_b)?;
let abs_a = self.cwd().join(&p.file_a);
let abs_b = self.cwd().join(&p.file_b);
let action = crate::cmd::doc::DocAction::Diff {
file_a: abs_a.to_string_lossy().into_owned(),
file_b: abs_b.to_string_lossy().into_owned(),
};
doc_readonly(&action)
}
#[tool(
description = "Search text files for a pattern (regex by default, use literal=true for exact match). Returns matches with line numbers and context. Options: files_with_matches, count, case_insensitive, multiline, invert_match, assert_count. Example: {\"pattern\": \"TODO\", \"paths\": [\"src/\"], \"literal\": true}"
)]
async fn search_files(
&self,
Parameters(p): Parameters<SearchParams>,
) -> Result<CallToolResult, McpError> {
if p.files_with_matches && p.count {
return Err(McpError::invalid_params(
"files_with_matches and count cannot be combined",
None,
));
}
if p.invert_match && p.multiline {
return Err(McpError::invalid_params(
"invert_match and multiline cannot be combined",
None,
));
}
validate_param_size("pattern", &p.pattern)?;
for path in &p.paths {
self.check_path(path)?;
}
let search_args = crate::cmd::search::SearchArgs {
pattern: p.pattern,
paths: if p.paths.is_empty() {
vec![".".into()]
} else {
p.paths
},
literal: p.literal,
regex: !p.literal,
context: p.context,
before_context: p.before_context,
after_context: p.after_context,
files_with_matches: p.files_with_matches,
count: p.count,
invert_match: p.invert_match,
multiline: p.multiline,
case_insensitive: p.case_insensitive,
assert_count: p.assert_count,
};
let global = GlobalFlags {
json: true,
cwd: Some(self.cwd().to_string_lossy().into_owned()),
..GlobalFlags::default()
};
let results = crate::cmd::search::collect_matches(&search_args, &global)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
if let Some(expected) = p.assert_count {
let actual: usize = results.file_match_counts.values().sum();
let matched = actual == expected;
let status = if matched {
"success"
} else {
"changes_detected"
};
let code = if matched {
exit::SUCCESS
} else {
exit::CHANGES_DETECTED
};
let output = serde_json::json!({
"ok": true,
"status": status,
"assert_count": {
"expected": expected,
"actual": actual,
"matched": matched,
}
});
return exit_code_to_result(code, &output.to_string(), "");
}
let has_matches = if search_args.count || search_args.files_with_matches {
!results.file_match_counts.is_empty()
} else {
results.has_matches()
};
if !has_matches {
return exit_code_to_result(exit::NO_MATCHES, "", "No matches found.");
}
let output = crate::cmd::search::format_results(results, &search_args, &global)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
exit_code_to_result(exit::SUCCESS, &output, "No results.")
}
#[tool(
description = "Show uncommitted file changes vs git HEAD. Returns lists of modified, created, and deleted files. No parameters required."
)]
async fn git_status(
&self,
#[allow(unused_variables)] Parameters(p): Parameters<serde_json::Value>,
) -> Result<CallToolResult, McpError> {
let global = GlobalFlags {
cwd: Some(self.cwd().to_string_lossy().into_owned()),
..GlobalFlags::default()
};
let status = crate::cmd::status::collect_status(&[], &global)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
let json = serde_json::to_string_pretty(&status)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Read file contents with optional line range. Example: {\"path\": \"src/main.rs\", \"lines\": \"1:50\"}"
)]
async fn read_file(
&self,
Parameters(p): Parameters<ReadFileParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
execute_plan_validated(
make_plan(vec![Operation::Read {
path: p.path,
lines: p.lines,
}]),
self.cwd(),
)
}
#[tool(
description = "Replace text in a file. Literal by default; set regex=true for regex. Options: nth, insert_before, insert_after, case_insensitive, multiline, if_exists, whole_line, range, word_boundary. Set word_boundary=true to match only whole words (prevents 'SetupFile' matching inside 'BenchSetupFile'). Set whole_line=true to replace entire lines containing a match (use with to=\"\" to delete lines). Example: {\"path\": \"README.md\", \"from\": \"1.0.0\", \"to\": \"2.0.0\"}"
)]
async fn replace_text(
&self,
Parameters(p): Parameters<ReplaceParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("from", &p.from)?;
if let Some(ref to) = p.to {
validate_content_size("to", to)?;
}
if let Some(ref ib) = p.insert_before {
validate_content_size("insert_before", ib)?;
}
if let Some(ref ia) = p.insert_after {
validate_content_size("insert_after", ia)?;
}
let validation_warnings = if !p.regex {
let abs = self.cwd().join(&p.path);
if let Ok(content) = std::fs::read_to_string(&abs) {
let to_str = p.to.as_deref().unwrap_or("");
let result =
crate::fallback::validate_edit(&content, &p.from, to_str, Some(&p.path));
let mut warnings = result.warnings;
if !result.valid {
warnings.extend(result.errors);
}
warnings
} else {
Vec::new()
}
} else {
Vec::new()
};
let mode = if p.regex {
Some("regex".to_string())
} else {
None
};
let mut tool_result = execute_plan_validated(
make_plan_strict(
vec![Operation::Replace {
glob: None,
path: Some(p.path),
mode,
from: p.from,
to: p.to,
nth: p.nth,
insert_before: p.insert_before,
insert_after: p.insert_after,
case_insensitive: p.case_insensitive,
multiline: p.multiline,
if_exists: p.if_exists,
whole_line: p.whole_line,
range: p.range,
word_boundary: p.word_boundary,
before_context: p.before_context,
after_context: p.after_context,
}],
Some(p.strict),
),
self.cwd(),
)?;
if !validation_warnings.is_empty() {
let warning_text = format!(
"\n\nWarnings:\n{}",
validation_warnings
.iter()
.map(|w| format!(" - {w}"))
.collect::<Vec<_>>()
.join("\n")
);
tool_result.content.push(Content::text(warning_text));
}
Ok(tool_result)
}
#[tool(
description = "Add a bullet under a markdown heading. Idempotent: skipped if already present. Example: {\"path\": \"CHANGELOG.md\", \"heading\": \"## Changes\", \"bullet\": \"- Added new feature\"}"
)]
async fn md_upsert_bullet(
&self,
Parameters(p): Parameters<MdUpsertBulletParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("bullet", &p.bullet)?;
execute_plan_validated(
make_plan(vec![Operation::MdUpsertBullet {
path: p.path,
heading: p.heading,
bullet: p.bullet,
}]),
self.cwd(),
)
}
#[tool(
description = "Append a row to a markdown table under a heading. Example: {\"path\": \"README.md\", \"heading\": \"## Commands\", \"row\": \"| deploy | Run deployment |\"}"
)]
async fn md_table_append(
&self,
Parameters(p): Parameters<MdTableAppendParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_param_size("row", &p.row)?;
execute_plan_validated(
make_plan(vec![Operation::MdTableAppend {
path: p.path,
heading: p.heading,
row: p.row,
}]),
self.cwd(),
)
}
#[tool(
description = "Replace the body of a markdown section. Content replaces everything between this heading and the next equal-or-higher heading. Example: {\"path\": \"README.md\", \"heading\": \"## Usage\", \"content\": \"Run `make build`.\\n\"}"
)]
async fn md_replace_section(
&self,
Parameters(p): Parameters<MdReplaceSectionParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_content_size("content", &p.content)?;
execute_plan_validated(
make_plan(vec![Operation::MdReplaceSection {
path: p.path,
heading: p.heading,
content: p.content,
}]),
self.cwd(),
)
}
#[tool(
description = "Insert content after a markdown heading. Preserves existing body. Example: {\"path\": \"README.md\", \"heading\": \"## Notes\", \"content\": \"Updated 2025-01-01.\\n\"}"
)]
async fn md_insert_after_heading(
&self,
Parameters(p): Parameters<MdInsertParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_content_size("content", &p.content)?;
execute_plan_validated(
make_plan(vec![Operation::MdInsertAfterHeading {
path: p.path,
heading: p.heading,
content: p.content,
}]),
self.cwd(),
)
}
#[tool(
description = "Insert content before a markdown heading. The new content appears above the heading line. Example: {\"path\": \"doc.md\", \"heading\": \"## API\", \"content\": \"---\\n\"}"
)]
async fn md_insert_before_heading(
&self,
Parameters(p): Parameters<MdInsertParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_content_size("content", &p.content)?;
execute_plan_validated(
make_plan(vec![Operation::MdInsertBeforeHeading {
path: p.path,
heading: p.heading,
content: p.content,
}]),
self.cwd(),
)
}
#[tool(
description = "Move a markdown heading section to a new position (same file reorder or cross-file). Exactly one of before or after is required. Omit to for same-file reorder. Example: {\"path\": \"spec.md\", \"heading\": \"## Appendix\", \"to\": \"notes.md\", \"before\": \"## References\"}"
)]
async fn md_move_section(
&self,
Parameters(p): Parameters<MdMoveSectionParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
if let Some(ref to) = p.to {
self.check_path(to)?;
}
if p.before.is_none() && p.after.is_none() {
return Err(McpError::invalid_params(
"exactly one of 'before' or 'after' must be provided",
None,
));
}
if p.before.is_some() && p.after.is_some() {
return Err(McpError::invalid_params(
"'before' and 'after' cannot both be set",
None,
));
}
execute_plan_validated(
make_plan(vec![Operation::MdMoveSection {
path: p.path,
heading: p.heading,
to: p.to,
before: p.before,
after: p.after,
}]),
self.cwd(),
)
}
#[tool(
description = "Lint a markdown rules file for duplicate headings, dangerous git commands, and missing final newline. Example: {\"path\": \"AGENTS.md\"}"
)]
async fn md_lint(
&self,
Parameters(p): Parameters<MdLintAgentsParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
let abs = self.cwd().join(&p.path);
let content = std::fs::read_to_string(&abs)
.map_err(|e| McpError::internal_error(format!("reading {}: {e}", p.path), None))?;
let issues = crate::cmd::md::lint_agents_content(&content);
let json = serde_json::to_string_pretty(&issues)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Fix whitespace in a file: trims trailing spaces and ensures final newline. Safe to call on any file (no-op if already clean). Example: {\"path\": \"dirty.txt\"}"
)]
async fn fix_whitespace(
&self,
Parameters(p): Parameters<TidyParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
execute_plan_validated(
make_plan(vec![Operation::TidyFix {
path: p.path,
ensure_final_newline: Some(true),
trim_trailing_whitespace: Some(true),
normalize_eol: None,
}]),
self.cwd(),
)
}
#[tool(
description = "Rename (move) a file. Handles binary files. Example: {\"from\": \"old.txt\", \"to\": \"new.txt\"}"
)]
async fn move_file(
&self,
Parameters(p): Parameters<FileRenameParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.from)?;
self.check_path(&p.to)?;
let src = self.cwd().join(&p.from);
let dst = self.cwd().join(&p.to);
match crate::cmd::rename::apply_rename(&src, &dst, p.force, self.cwd()) {
Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
"Renamed {} -> {}",
p.from, p.to
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!("{e:#}"))])),
}
}
#[tool(
description = "Append content to the end of an existing file. Inserts a newline separator if the file does not end with one. Fails if the file does not exist. Example: {\"path\": \"tests.rs\", \"content\": \"#[test]\\nfn new() {}\"}"
)]
async fn append_file(
&self,
Parameters(p): Parameters<AppendFileParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_content_size("content", &p.content)?;
let abs = self.cwd().join(&p.path);
match crate::cmd::append::apply_append(&abs, &p.content) {
Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
"Appended to {}",
p.path
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!("{e:#}"))])),
}
}
#[tool(
description = "Create a new file with content. Fails if file exists unless force=true. Example: {\"path\": \"hello.txt\", \"content\": \"Hello, World!\"}"
)]
async fn create_file(
&self,
Parameters(p): Parameters<CreateFileParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
validate_content_size("content", &p.content)?;
let abs = self.cwd().join(&p.path);
match crate::cmd::create::apply_create(&abs, &p.content, p.force) {
Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
"Created {}",
p.path
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!("{e:#}"))])),
}
}
#[tool(
description = "Delete a file. Fails if the file does not exist. Example: {\"path\": \"temp.txt\"}"
)]
async fn delete_file(
&self,
Parameters(p): Parameters<DeleteFileParams>,
) -> Result<CallToolResult, McpError> {
self.check_path(&p.path)?;
let abs = self.cwd().join(&p.path);
match crate::cmd::delete::apply_delete(&abs, self.cwd()) {
Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
"Deleted {}",
p.path
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!("{e:#}"))])),
}
}
#[tool(
description = "Apply a unified diff (patch). The diff parameter is the full unified diff text. Supports multi-file diffs. Use on_stale=merge for three-way merge on stale context; allow_conflicts=true writes conflict markers. Never commit files containing conflict markers. Example: {\"diff\": \"--- a/file.txt\\n+++ b/file.txt\\n@@ -1 +1 @@\\n-old\\n+new\", \"on_stale\": \"fail\"}"
)]
async fn apply_patch(
&self,
Parameters(p): Parameters<PatchParams>,
) -> Result<CallToolResult, McpError> {
validate_content_size("diff", &p.diff)?;
let patch_files = crate::ops::patch::parse_patch(&p.diff)
.map_err(|e| McpError::invalid_params(format!("failed to parse diff: {e}"), None))?;
for pf in &patch_files {
self.check_path(&pf.path)?;
}
execute_plan_validated(
make_plan_strict(
vec![Operation::PatchApply {
diff: p.diff,
on_stale: p.on_stale,
allow_conflicts: p.allow_conflicts,
}],
Some(p.strict),
),
self.cwd(),
)
}
#[tool(
description = "Replace the same text across multiple files in one call. Atomic: all files succeed or none change. Example: {\"files\": [\"Cargo.toml\", \"README.md\"], \"from\": \"0.1.0\", \"to\": \"0.2.0\"}"
)]
async fn batch_replace(
&self,
Parameters(p): Parameters<BatchReplaceParams>,
) -> Result<CallToolResult, McpError> {
if p.files.is_empty() {
return Err(McpError::invalid_params(
"files array must not be empty",
None,
));
}
validate_batch_size("files", p.files.len())?;
validate_param_size("from", &p.from)?;
validate_content_size("to", &p.to)?;
for f in &p.files {
self.check_path(f)?;
}
let mode = if p.regex {
Some("regex".to_string())
} else {
None
};
let ops: Vec<Operation> = p
.files
.into_iter()
.map(|file| Operation::Replace {
glob: None,
path: Some(file),
mode: mode.clone(),
from: p.from.clone(),
to: Some(p.to.clone()),
nth: None,
insert_before: None,
insert_after: None,
case_insensitive: p.case_insensitive,
multiline: p.multiline,
if_exists: false,
whole_line: false,
range: None,
word_boundary: p.word_boundary,
before_context: None,
after_context: None,
})
.collect();
execute_plan_validated(make_plan_strict(ops, Some(p.strict)), self.cwd())
}
#[tool(
description = "Fix whitespace in multiple files in one call: trims trailing spaces and ensures final newline. Atomic: all files succeed or none change. Example: {\"files\": [\"src/main.rs\", \"src/lib.rs\"]}"
)]
async fn batch_tidy(
&self,
Parameters(p): Parameters<BatchTidyParams>,
) -> Result<CallToolResult, McpError> {
if p.files.is_empty() {
return Err(McpError::invalid_params(
"files array must not be empty",
None,
));
}
validate_batch_size("files", p.files.len())?;
for f in &p.files {
self.check_path(f)?;
}
let ops: Vec<Operation> = p
.files
.into_iter()
.map(|file| Operation::TidyFix {
path: file,
ensure_final_newline: Some(true),
trim_trailing_whitespace: Some(true),
normalize_eol: None,
})
.collect();
execute_plan_validated(make_plan_strict(ops, Some(p.strict)), self.cwd())
}
}
impl ServerHandler for PatchloomService {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_instructions(
"Use these tools for ALL file operations. Use batch_replace and batch_tidy when applying the same operation to multiple files.",
);
info.server_info.name = "patchloom".into();
info.server_info.version = env!("CARGO_PKG_VERSION").into();
info
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: self.tool_router.list_all(),
next_cursor: None,
meta: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let tool_name = request.name.clone();
crate::verbose!("mcp: tool call -> {tool_name}");
let start = std::time::Instant::now();
let tc = ToolCallContext::new(self, request, context);
let result = self.tool_router.call(tc).await;
let duration_ms = start.elapsed().as_millis() as u64;
crate::verbose!(
"mcp: {tool_name} completed in {duration_ms}ms (ok={})",
result.is_ok()
);
self.log_tool_call(&tool_name, duration_ms, &result);
result
}
}
pub(crate) fn run_mcp_server(global: &GlobalFlags, log: Option<String>) -> anyhow::Result<u8> {
let cwd = global.resolve_cwd()?;
let service = PatchloomService::new(cwd, log)?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let server = service
.serve(rmcp::transport::stdio())
.await
.map_err(|e| anyhow::anyhow!("MCP server error: {e}"))?;
server
.waiting()
.await
.map_err(|e| anyhow::anyhow!("MCP server error: {e}"))?;
Ok::<(), anyhow::Error>(())
})?;
Ok(crate::exit::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::ServiceExt;
async fn spawn_test_client(
cwd: std::path::PathBuf,
) -> rmcp::service::RunningService<rmcp::RoleClient, ()> {
let (server_transport, client_transport) = tokio::io::duplex(16384);
let service = PatchloomService::new(cwd, None).unwrap();
tokio::spawn(async move {
let server = service.serve(server_transport).await.unwrap();
server.waiting().await.unwrap();
});
().serve(client_transport).await.unwrap()
}
#[tokio::test]
async fn mcp_lists_expected_tools() {
let dir = tempfile::TempDir::new().unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let tools = client.peer().list_all_tools().await.unwrap();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
let descriptions = tools
.iter()
.map(|t| (t.name.as_ref(), t.description.as_deref().unwrap_or("")))
.collect::<std::collections::BTreeMap<_, _>>();
assert!(names.contains(&"doc_set"), "missing doc_set tool");
assert!(names.contains(&"doc_get"), "missing doc_get tool");
assert!(names.contains(&"doc_query"), "missing doc_query tool");
assert!(names.contains(&"doc_diff"), "missing doc_diff tool");
assert!(names.contains(&"read_file"), "missing read_file tool");
assert!(names.contains(&"search_files"), "missing search_files tool");
assert_eq!(
descriptions.get("search_files"),
Some(
&"Search text files for a pattern (regex by default, use literal=true for exact match). Returns matches with line numbers and context. Options: files_with_matches, count, case_insensitive, multiline, invert_match, assert_count. Example: {\"pattern\": \"TODO\", \"paths\": [\"src/\"], \"literal\": true}"
),
"search_files description drifted"
);
assert!(names.contains(&"git_status"), "missing git_status tool");
assert!(names.contains(&"replace_text"), "missing replace_text tool");
assert_eq!(
descriptions.get("replace_text"),
Some(
&"Replace text in a file. Literal by default; set regex=true for regex. Options: nth, insert_before, insert_after, case_insensitive, multiline, if_exists, whole_line, range, word_boundary. Set word_boundary=true to match only whole words (prevents 'SetupFile' matching inside 'BenchSetupFile'). Set whole_line=true to replace entire lines containing a match (use with to=\"\" to delete lines). Example: {\"path\": \"README.md\", \"from\": \"1.0.0\", \"to\": \"2.0.0\"}"
),
"replace_text description drifted"
);
assert!(
names.contains(&"fix_whitespace"),
"missing fix_whitespace tool"
);
assert_eq!(
descriptions.get("fix_whitespace"),
Some(
&"Fix whitespace in a file: trims trailing spaces and ensures final newline. Safe to call on any file (no-op if already clean). Example: {\"path\": \"dirty.txt\"}"
),
"fix_whitespace description drifted"
);
assert!(names.contains(&"move_file"), "missing move_file tool");
assert!(names.contains(&"create_file"), "missing create_file tool");
assert!(names.contains(&"delete_file"), "missing delete_file tool");
assert!(names.contains(&"apply_patch"), "missing apply_patch tool");
assert!(
names.contains(&"batch_replace"),
"missing batch_replace tool"
);
assert!(names.contains(&"batch_tidy"), "missing batch_tidy tool");
assert!(
names.contains(&"md_insert_after_heading"),
"missing md_insert_after_heading tool"
);
assert!(
names.contains(&"md_insert_before_heading"),
"missing md_insert_before_heading tool"
);
assert!(
names.contains(&"md_move_section"),
"missing md_move_section tool"
);
assert!(names.contains(&"append_file"), "missing append_file tool");
assert_eq!(names.len(), 31, "expected 31 tools, got {}", names.len());
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_server_info_has_correct_name() {
let dir = tempfile::TempDir::new().unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let info = client.peer_info().expect("peer info should be set");
assert_eq!(info.server_info.name, "patchloom");
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_path_traversal_rejected_via_protocol() {
let dir = tempfile::TempDir::new().unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let params = rmcp::model::CallToolRequestParams::new("doc_set").with_arguments(
serde_json::from_value(serde_json::json!({
"path": "../../etc/passwd",
"selector": "root",
"value": "hacked"
}))
.unwrap(),
);
let result = client.peer().call_tool(params).await;
assert!(result.is_err(), "path traversal should be rejected");
client.cancel().await.unwrap();
}
#[test]
fn patch_apply_path_containment_validation() {
let dir = tempfile::TempDir::new().unwrap();
let evil_diff =
"--- a/../../etc/passwd\n+++ b/../../etc/passwd\n@@ -1,1 +1,1 @@\n-root\n+hacked\n";
let ops = vec![Operation::PatchApply {
diff: evil_diff.to_string(),
on_stale: crate::ops::patch::OnStale::Fail,
allow_conflicts: false,
}];
let result = validate_operation_paths(&ops, dir.path());
assert!(
result.is_err(),
"PatchApply with escaping paths should be rejected"
);
}
#[test]
fn patch_apply_safe_paths_pass_containment() {
let dir = tempfile::TempDir::new().unwrap();
let safe_diff = "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n";
let ops = vec![Operation::PatchApply {
diff: safe_diff.to_string(),
on_stale: crate::ops::patch::OnStale::Fail,
allow_conflicts: false,
}];
let result = validate_operation_paths(&ops, dir.path());
assert!(result.is_ok(), "PatchApply with safe paths should pass");
}
#[tokio::test]
async fn mcp_example_argument_keys_match_schemas() {
let dir = tempfile::TempDir::new().unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let tools = client.peer().list_all_tools().await.unwrap();
let mut schema_keys: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for tool in &tools {
if let Some(props) = tool
.input_schema
.get("properties")
.and_then(|p| p.as_object())
{
schema_keys.insert(tool.name.to_string(), props.keys().cloned().collect());
}
}
let example_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/08-mcp-tool-call.json");
let example_content = std::fs::read_to_string(&example_path)
.expect("failed to read examples/08-mcp-tool-call.json");
let example_json: serde_json::Value = serde_json::from_str(&example_content)
.expect("failed to parse examples/08-mcp-tool-call.json");
let examples = example_json["examples"]
.as_array()
.expect("examples should be an array");
let mut errors = Vec::new();
for entry in examples {
let tool_name = entry["tool"].as_str().expect("tool should be a string");
let arguments = entry["arguments"]
.as_object()
.expect("arguments should be an object");
if let Some(valid_keys) = schema_keys.get(tool_name) {
for key in arguments.keys() {
if !valid_keys.contains(key) {
errors.push(format!(
"tool '{}': unknown argument '{}' (valid: {:?})",
tool_name, key, valid_keys
));
}
}
} else {
errors.push(format!("tool '{}': not found in MCP tool list", tool_name));
}
}
assert!(
errors.is_empty(),
"MCP example validation errors:\n{}",
errors.join("\n")
);
client.cancel().await.unwrap();
}
async fn spawn_test_client_with_log(
cwd: std::path::PathBuf,
log_path: std::path::PathBuf,
) -> rmcp::service::RunningService<rmcp::RoleClient, ()> {
let (server_transport, client_transport) = tokio::io::duplex(16384);
let service =
PatchloomService::new(cwd, Some(log_path.to_string_lossy().into_owned())).unwrap();
tokio::spawn(async move {
let server = service.serve(server_transport).await.unwrap();
server.waiting().await.unwrap();
});
().serve(client_transport).await.unwrap()
}
#[tokio::test]
async fn mcp_log_writes_jsonl_on_tool_call() {
let dir = tempfile::TempDir::new().unwrap();
let log_file = dir.path().join("mcp.log");
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let client = spawn_test_client_with_log(dir.path().to_path_buf(), log_file.clone()).await;
let params = rmcp::model::CallToolRequestParams::new("read_file").with_arguments(
serde_json::from_value(serde_json::json!({
"path": "test.txt",
}))
.unwrap(),
);
let result = client.peer().call_tool(params).await.unwrap();
assert!(
result.is_error != Some(true),
"read_file should succeed: {result:?}"
);
client.cancel().await.unwrap();
let log_content = std::fs::read_to_string(&log_file).expect("log file should exist");
let lines: Vec<&str> = log_content.trim().lines().collect();
assert_eq!(lines.len(), 1, "expected exactly 1 log line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line should be valid JSON");
assert_eq!(entry["tool"], "read_file");
assert_eq!(entry["ok"], true);
assert!(entry["ts"].is_number(), "ts should be a number");
assert!(
entry["duration_ms"].is_number(),
"duration_ms should be a number"
);
}
#[tokio::test]
async fn mcp_log_records_error_on_failure() {
let dir = tempfile::TempDir::new().unwrap();
let log_file = dir.path().join("mcp.log");
let client = spawn_test_client_with_log(dir.path().to_path_buf(), log_file.clone()).await;
let params = rmcp::model::CallToolRequestParams::new("doc_set").with_arguments(
serde_json::from_value(serde_json::json!({
"path": "nonexistent.json",
"selector": "key",
"value": "val",
}))
.unwrap(),
);
let _result = client.peer().call_tool(params).await;
client.cancel().await.unwrap();
let log_content = std::fs::read_to_string(&log_file).expect("log file should exist");
let lines: Vec<&str> = log_content.trim().lines().collect();
assert_eq!(lines.len(), 1, "expected exactly 1 log line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line should be valid JSON");
assert_eq!(entry["tool"], "doc_set");
assert_eq!(entry["ok"], false);
}
#[test]
fn validate_content_size_accepts_small() {
assert!(validate_content_size("field", "hello").is_ok());
}
#[test]
fn validate_content_size_rejects_oversized() {
let big = "x".repeat(MAX_CONTENT_BYTES + 1);
let err = validate_content_size("content", &big).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("content"), "error should name the field");
assert!(msg.contains("exceeds"), "error should say exceeds");
}
#[test]
fn validate_param_size_accepts_small() {
assert!(validate_param_size("selector", "a.b.c").is_ok());
}
#[test]
fn validate_param_size_rejects_oversized() {
let big = "x".repeat(MAX_PARAM_BYTES + 1);
let err = validate_param_size("pattern", &big).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("pattern"), "error should name the field");
}
#[test]
fn validate_batch_size_accepts_small() {
assert!(validate_batch_size("files", 5).is_ok());
}
#[test]
fn validate_batch_size_rejects_oversized() {
let err = validate_batch_size("files", MAX_BATCH_FILES + 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("files"), "error should name the field");
}
#[test]
fn validate_json_depth_accepts_shallow() {
let val = serde_json::json!({"a": {"b": "c"}});
assert!(validate_json_depth("value", &val).is_ok());
}
#[test]
fn validate_json_depth_accepts_scalar() {
let val = serde_json::json!("hello");
assert!(validate_json_depth("value", &val).is_ok());
}
#[test]
fn validate_json_depth_rejects_deeply_nested() {
let mut val = serde_json::json!("leaf");
for _ in 0..MAX_JSON_DEPTH + 1 {
val = serde_json::json!([val]);
}
let err = validate_json_depth("value", &val).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("nesting depth"), "error should mention depth");
}
#[tokio::test]
async fn mcp_rejects_oversized_content_via_protocol() {
let dir = tempfile::TempDir::new().unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let big_content = "x".repeat(MAX_CONTENT_BYTES + 1);
let params = rmcp::model::CallToolRequestParams::new("create_file").with_arguments(
serde_json::from_value(serde_json::json!({
"path": "big.txt",
"content": big_content,
}))
.unwrap(),
);
let result = client.peer().call_tool(params).await;
assert!(result.is_err(), "oversized content should be rejected");
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_rejects_oversized_batch() {
let dir = tempfile::TempDir::new().unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let files: Vec<String> = (0..MAX_BATCH_FILES + 1)
.map(|i| format!("file{i}.txt"))
.collect();
let params = rmcp::model::CallToolRequestParams::new("batch_tidy").with_arguments(
serde_json::from_value(serde_json::json!({
"files": files,
}))
.unwrap(),
);
let result = client.peer().call_tool(params).await;
assert!(result.is_err(), "oversized batch should be rejected");
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_rejects_oversized_doc_query_selector() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("data.json"), r#"{"a":1}"#).unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let big_selector = "x".repeat(MAX_PARAM_BYTES + 1);
let params = rmcp::model::CallToolRequestParams::new("doc_query").with_arguments(
serde_json::from_value(serde_json::json!({
"action": "has",
"path": "data.json",
"selector": big_selector,
}))
.unwrap(),
);
let result = client.peer().call_tool(params).await;
assert!(result.is_err(), "oversized selector should be rejected");
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_no_log_without_flag() {
let dir = tempfile::TempDir::new().unwrap();
let log_file = dir.path().join("mcp.log");
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
let client = spawn_test_client(dir.path().to_path_buf()).await;
let params = rmcp::model::CallToolRequestParams::new("read_file").with_arguments(
serde_json::from_value(serde_json::json!({
"path": "test.txt",
}))
.unwrap(),
);
let _result = client.peer().call_tool(params).await.unwrap();
client.cancel().await.unwrap();
assert!(
!log_file.exists(),
"log file should not exist without --log"
);
}
}