Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion crates/sprout-dev-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use std::path::Path;
use std::sync::Arc;

mod paths;
mod read_file;
mod rg;
mod shell;
mod shim;
Expand Down Expand Up @@ -47,6 +48,17 @@ impl DevMcp {
shell::run(&self.state, p, context.ct).await
}

#[tool(
name = "read_file",
description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail."
)]
async fn read_file(
&self,
Parameters(p): Parameters<read_file::ReadFileParams>,
) -> Result<String, ErrorData> {
read_file::run(&self.state, p)
}

#[tool(
name = "view_image",
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
Expand All @@ -60,7 +72,7 @@ impl DevMcp {

#[tool(
name = "str_replace",
description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
description = "Atomic find-and-replace in a file. old_str must occur exactly once unless replace_all is true, in which case all occurrences are replaced. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
)]
async fn str_replace(
&self,
Expand Down
127 changes: 104 additions & 23 deletions crates/sprout-dev-mcp/src/paths.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,117 @@
//! Path resolution shared across dev-mcp tools.
//! Path resolution and file I/O shared across dev-mcp tools.
//!
//! `resolve_within` canonicalises a user-supplied path against a workspace
//! root and rejects any result that escapes the root (e.g. via `..`, absolute
//! paths, or symlinks). All tools that touch the filesystem must funnel
//! through this helper so the escape policy stays consistent.
//! `resolve_path` resolves and canonicalizes a user-supplied path against a
//! workspace root. No containment enforcement — the resolved path may land
//! anywhere on the filesystem (consistent with the `shell` tool's posture).
//!
//! `read_text_file` builds on `resolve_path` to provide the full
//! resolve → stat → size-check → read → UTF-8 decode pipeline shared by
//! `read_file` and `str_replace`.

use crate::shell::SharedState;
use rmcp::ErrorData;
use std::path::{Path, PathBuf};

/// Resolve `path` (absolute or relative) against `root` and require the
/// canonicalised result to live under the canonicalised `root`. Returns an
/// error string suitable for `ErrorData::invalid_params` on rejection.
pub(crate) fn resolve_within(root: &Path, path: &str) -> Result<PathBuf, String> {
pub(crate) const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;

/// Resolve `path` (absolute or relative) against `root` and canonicalize
/// the result. Returns an error string suitable for `ErrorData::invalid_params`
/// if the path cannot be resolved.
pub(crate) fn resolve_path(root: &Path, path: &str) -> Result<PathBuf, String> {
let raw = Path::new(path);
let candidate: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};

let root_canon = std::fs::canonicalize(root)
.map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?;

let resolved = std::fs::canonicalize(&candidate)
.map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?;

if !resolved.starts_with(&root_canon) {
return Err(format!(
"path escapes workspace: {} not within {}",
resolved.display(),
root_canon.display()
Ok(resolved)
}

/// Resolve a user-supplied path within the workspace, read the file, and
/// return `(resolved_path, utf8_content)`. Rejects files that are not
/// regular files, exceed `MAX_FILE_BYTES`, or are not valid UTF-8.
pub(crate) fn read_text_file(
state: &SharedState,
path: &str,
workdir: Option<&str>,
) -> Result<(PathBuf, String), ErrorData> {
let workspace_root: PathBuf = match workdir {
Some(w) => PathBuf::from(w),
None => state.cwd.clone(),
};
let target = match resolve_path(&workspace_root, path) {
Ok(t) => t,
Err(e) => return Err(ErrorData::invalid_params(e, None)),
};

let meta = match std::fs::metadata(&target) {
Ok(m) => m,
Err(e) => {
return Err(ErrorData::internal_error(
format!("cannot stat {}: {e}", target.display()),
None,
));
}
};
if !meta.is_file() {
return Err(ErrorData::invalid_params(
format!("not a regular file: {}", target.display()),
None,
));
}
Ok(resolved)
if meta.len() > MAX_FILE_BYTES {
return Err(ErrorData::invalid_params(
format!(
"file too large: {} is {} bytes (limit {} bytes)",
target.display(),
meta.len(),
MAX_FILE_BYTES
),
None,
));
}

let file = match std::fs::File::open(&target) {
Ok(f) => f,
Err(e) => {
return Err(ErrorData::internal_error(
format!("cannot open {}: {e}", target.display()),
None,
));
}
};
let mut buf = Vec::with_capacity(meta.len() as usize);
use std::io::Read;
match file.take(MAX_FILE_BYTES + 1).read_to_end(&mut buf) {
Ok(n) if n as u64 > MAX_FILE_BYTES => {
return Err(ErrorData::invalid_params(
format!("file grew past {} bytes during read", MAX_FILE_BYTES),
None,
));
}
Ok(_) => {}
Err(e) => {
return Err(ErrorData::internal_error(
format!("cannot read {}: {e}", target.display()),
None,
));
}
}
let content = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => {
return Err(ErrorData::internal_error(
format!("not valid UTF-8: {}: {e}", target.display()),
None,
));
}
};

Ok((target, content))
}

#[cfg(test)]
Expand All @@ -41,24 +121,25 @@ mod tests {
use tempfile::tempdir;

#[test]
fn resolve_within_rejects_escape() {
fn resolve_path_allows_outside_workspace() {
let dir = tempdir().expect("tempdir");
let inside = dir.path().join("file.txt");
fs::write(&inside, b"x").expect("write");
// Symlink targeting outside the dir should be rejected.
// Symlink targeting outside the dir should now resolve successfully.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join("dev-mcp-paths-escape-target");
let _ = fs::remove_file(&outside);
fs::write(&outside, b"y").expect("write outside");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&outside, &link).expect("symlink");
let err = resolve_within(dir.path(), "link.txt").unwrap_err();
assert!(err.contains("escapes workspace"), "got: {err}");
let resolved = resolve_path(dir.path(), "link.txt").expect("resolve");
let outside_canon = std::fs::canonicalize(&outside).expect("canonicalize");
assert_eq!(resolved, outside_canon);
let _ = fs::remove_file(&outside);
}
// Resolves a normal path inside.
let p = resolve_within(dir.path(), "file.txt").expect("resolve");
let p = resolve_path(dir.path(), "file.txt").expect("resolve");
assert!(p.ends_with("file.txt"));
}
}
Loading