use rig::{
message::ToolResultContent,
tool::{Tool, ToolContext},
};
use rmcp::{
RoleClient, ServiceExt,
model::{
CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, Tool as RmcpTool,
},
service::{ClientInitializeError, RunningService},
transport::StreamableHttpClientTransport,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
collections::BTreeMap,
fs::{self, read_to_string},
io::ErrorKind,
path::{Path, PathBuf},
};
use crate::{
config::{self, McpTool, WebSearchProvider},
llm::compactors::SharedObservations,
};
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum DirectoryEntryKind {
File,
Directory,
}
impl std::fmt::Display for DirectoryEntryKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DirectoryEntryKind::File => {
write!(f, "File")
}
DirectoryEntryKind::Directory => {
write!(f, "Directory")
}
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct DirectoryEntry {
path: String,
kind: DirectoryEntryKind,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DirectoryListing {
entries: Vec<DirectoryEntry>,
total_entries: usize,
truncated: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Location {
path: PathBuf,
line: usize,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Range {
path: PathBuf,
start: usize,
end: usize,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct SearchMatch {
location: Location,
preview: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SearchResults {
total_entries: usize,
returned_entries: usize,
truncated: bool,
entries: Vec<SearchMatch>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FileContent {
range: Range,
content: String,
}
#[derive(Deserialize, Serialize)]
pub struct ListDirArgs {
path: PathBuf,
start: Option<usize>,
}
#[derive(Deserialize, Serialize)]
pub struct ListDir {
base_dir: PathBuf,
#[serde(default)]
max_results: Option<usize>,
}
impl ListDir {
pub fn new(base_dir: &Path, max_results: Option<usize>) -> Self {
Self {
base_dir: base_dir.to_path_buf(),
max_results,
}
}
}
impl Tool for ListDir {
const NAME: &'static str = "list_dir";
type Error = ToolError;
type Args = ListDirArgs;
type Output = DirectoryListing;
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let path = allowed_canonical_path(&args.path, &self.base_dir)?;
let mut result = list_dir(&path, false)?;
result.total_entries = result.entries.len();
if let Some(max_results) = self.max_results
&& result.entries.len() > max_results
{
result.entries.truncate(max_results);
result.truncated = true;
}
Ok(result)
}
fn description(&self) -> String {
format!(
"List the files and directories in the given directory (relative to project root), \
returning {} entries. Use an empty string \"\" for the project root. \
Results include both files and subdirectories with their types.",
match self.max_results {
Some(val) => format!("at most {}", val),
None => "all".to_string(),
}
)
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the directory relative to project root directory. \
Use an empty string for the root directory."
},
"max_results": {
"type": "integer",
"description": "Optional, limit the number of entries to return to at most the given value."
},
},
"required": ["path"]
})
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}
#[derive(Deserialize, Serialize)]
pub struct ReadFileArgs {
path: PathBuf,
#[serde(default)]
start: Option<usize>,
#[serde(default)]
end: Option<usize>,
}
#[derive(Deserialize, Serialize)]
pub struct ReadFile {
base_dir: PathBuf,
}
impl ReadFile {
pub fn new(base_dir: &Path) -> Self {
Self {
base_dir: base_dir.to_path_buf(),
}
}
}
impl Tool for ReadFile {
const NAME: &'static str = "read_file";
type Error = ToolError;
type Args = ReadFileArgs;
type Output = FileContent;
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let path = allowed_canonical_path(&args.path, &self.base_dir)?;
if path.is_dir() {
return Err(ToolError::NotAFile {
path: args.path.to_string_lossy().to_string(),
suggestion: "Use list_dir to explore this directory instead.".to_string(),
});
}
let content: Vec<String> = fs::read_to_string(path)
.map_err(|err| io_error_to_tool_error(err, &args.path))?
.lines()
.map(|line| line.to_string())
.collect();
let mut start = match args.start {
Some(v) => v.saturating_sub(1),
None => 0,
};
let mut end = match args.end {
Some(v) => {
if v > 0 {
v } else {
1 }
}
None => content.len(),
};
if end > content.len() {
end = content.len();
}
if start > end {
start = end;
};
let extract = &content[start..end].join("\n");
Ok(FileContent {
range: Range {
path: args.path,
start: start + 1,
end: end + 1,
},
content: extract.to_owned(),
})
}
fn description(&self) -> String {
"Read the content of a file, optionally specifying a line range. \
Lines are 1-indexed and inclusive. \
If no range is specified, the entire file is returned."
.to_string()
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file relative to project root directory"
},
"start": {
"type": "integer",
"description": "First line number to read (1-indexed, inclusive). \
If not provided, reads from the beginning of the file."
},
"end": {
"type": "integer",
"description": "Last line number to read (1-indexed, inclusive). \
If not provided, reads to the end of the file. \
If end exceeds the file length, reads until the end."
}
},
"required": ["path"]
})
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}
#[derive(Deserialize, Serialize)]
pub struct SearchTextArgs {
path: PathBuf,
text: String,
#[serde(default)]
start: Option<usize>,
}
#[derive(Deserialize, Serialize)]
pub struct SearchText {
base_dir: PathBuf,
max_results: usize,
}
impl SearchText {
pub fn new(base_dir: &Path, max_results: usize) -> Self {
Self {
base_dir: base_dir.to_path_buf(),
max_results,
}
}
}
impl Tool for SearchText {
const NAME: &'static str = "search_text";
type Error = ToolError;
type Args = SearchTextArgs;
type Output = SearchResults;
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let path = allowed_canonical_path(&args.path, &self.base_dir)?;
let entries = list_dir(&path, true)?;
let matches: Vec<SearchMatch> = entries
.entries
.iter()
.filter_map(|e| match e.kind {
DirectoryEntryKind::File => {
read_to_string(&e.path).ok().map(|v| (e.path.clone(), v))
}
DirectoryEntryKind::Directory => None,
})
.map(|(path, content)| {
(
path,
content
.lines()
.enumerate()
.filter(|(_nr, line)| line.contains(&args.text))
.map(|(nr, line)| (nr, line.to_owned()))
.collect::<Vec<(usize, String)>>(),
)
})
.filter(|(_path, content)| !content.is_empty())
.flat_map(|(path, content)| {
content
.iter()
.map(move |(nr, line)| SearchMatch {
location: Location {
path: PathBuf::from(path.clone()),
line: *nr,
},
preview: line.clone(),
})
.collect::<Vec<_>>()
})
.collect();
let start = args.start.unwrap_or_default();
let requested_matches = &matches[start..];
let final_matches = if requested_matches.len() > 100 {
&requested_matches[..100]
} else {
requested_matches
};
Ok(SearchResults {
total_entries: matches.len(),
returned_entries: final_matches.len(),
truncated: final_matches.len() != matches.len(),
entries: final_matches.to_vec(),
})
}
fn description(&self) -> String {
format!(
"Search for text in all files within the given directory and its subdirectories. \
Returns matches with file paths, line numbers, and content previews. \
The search is case-sensitive. Results are clipped at {} entries.",
self.max_results
)
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the directory to search in, \
relative to project root directory. \
Use an empty string for the root directory. \
Search is recursive."
},
"text": {
"type": "string",
"description": "The exact text string to search for. \
The search is case-sensitive."
},
"start": {
"type": "integer",
"description": "Optional, zero-based index of the first result to return. \
Use this to paginate through results if a previous call was truncated."
},
},
"required": ["path", "text"]
})
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}
#[derive(Deserialize, Serialize)]
pub struct RetrieveObservationArgs {
id: u64,
}
#[derive(Debug)]
pub struct RetrieveObservation {
pub observations: SharedObservations,
}
impl RetrieveObservation {
pub fn new(observations: SharedObservations) -> Self {
Self { observations }
}
}
impl Tool for RetrieveObservation {
const NAME: &'static str = "retrieve_observation";
type Error = ToolError;
type Args = RetrieveObservationArgs;
type Output = Vec<ToolResultContent>;
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
self.observations
.lock()
.unwrap()
.get(&args.id)
.ok_or_else(|| ToolError::ResourceNotFound {
resource_type: ResourceType::Observation,
identifier: args.id.to_string(),
})
.cloned()
}
fn description(&self) -> String {
"Retrieve the full content of a masked tool result from conversation history. \
When conversation history is compacted to save context space, older tool results \
are replaced with placeholders like '[Observation:3 - masked]'. \
Use this tool when you need to see the full content of a masked result. \
The number inside the brackets (e.g., 3 in '[Observation:3 - masked]') is the \
id you should pass to this tool. Each tool result in the history is assigned a \
unique observation number starting from 1."
.to_string()
}
fn parameters(&self) -> serde_json::Value {
let id_description = "The numeric identifier of the observation to retrieve. \
When a tool result is masked in the conversation history, it appears as \
'[Observation:N - masked]' where N is this identifier. For example, \
if you see '[Observation:3 - masked]', pass 3 as this parameter. \
Observation IDs start at 1 and increment sequentially.";
json!({
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": id_description
},
},
"required": ["id"]
})
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}
#[derive(Deserialize, Serialize)]
pub struct WebSearchArgs {
query: String,
#[serde(default = "web_search_max_results_default")]
max_results: u64,
}
fn web_search_max_results_default() -> u64 {
5
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WebSearchResult {
url: String,
title: String,
content: String,
}
#[derive(Debug)]
pub struct WebSearch {
tool: RmcpTool,
service: RunningService<RoleClient, ClientInfo>,
provider: WebSearchProvider,
}
impl WebSearch {
pub async fn new(config: &config::WebSearch) -> Option<Self> {
let tool = get_mcp_tools(&BTreeMap::from([(
config.provider.display_string(),
McpTool {
url: config.url.clone(),
},
)]))
.await;
if let Some((service, tools)) = tool.into_iter().next() {
tools
.iter()
.find(|tool| tool.name == "tavily_search")
.map(|tool| Self {
tool: tool.to_owned(),
service,
provider: config.provider,
})
} else {
None
}
}
}
impl Tool for WebSearch {
const NAME: &'static str = "web_search";
type Error = ToolError;
type Args = WebSearchArgs;
type Output = Vec<WebSearchResult>;
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let arguments = serde_json::to_value(&args)
.unwrap()
.as_object()
.cloned()
.unwrap();
let params = CallToolRequestParams::new("tavily_search").with_arguments(arguments);
let results = self
.service
.call_tool(params)
.await
.map_err(|err| match err {
rmcp::ServiceError::McpError(error_data) => ToolError::Mcp {
kind: "MCP".to_string(),
info: error_data.to_string(),
},
rmcp::ServiceError::TransportSend(dynamic_transport_error) => ToolError::Mcp {
kind: "Transport send".to_string(),
info: dynamic_transport_error.to_string(),
},
rmcp::ServiceError::TransportClosed => ToolError::Mcp {
kind: "Transport closed".to_string(),
info: String::new(),
},
rmcp::ServiceError::UnexpectedResponse => ToolError::Mcp {
kind: "Unexpected response".to_string(),
info: String::new(),
},
rmcp::ServiceError::Cancelled { reason } => ToolError::Mcp {
kind: "Cancelled".to_string(),
info: reason.unwrap_or_default(),
},
rmcp::ServiceError::Timeout { timeout } => ToolError::Mcp {
kind: "Cancelled".to_string(),
info: String::new(),
},
_ => ToolError::Mcp {
kind: "Unknown".to_string(),
info: String::new(),
},
})?;
println!("{:?}", results);
Ok(Vec::new())
}
fn description(&self) -> String {
"Search the internet for up-to-date information, recent events, live data, \
or obscure facts not covered in your training data. \
Use when the user asks for current news, real-time statuses, \
or when you need to verify uncertain information. \
Do not use for common knowledge or questions answerable with local tools."
.to_string()
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A concise, specific search query. Use keywords or short phrases. \
Avoid full sentences or overly broad terms for better results."
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return. Recommended: 3-5. \
Higher values increase latency and token usage but may improve coverage. \
Defaults to 5 if omitted.",
"default": 5
},
},
"required": ["query"]
})
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}
fn list_dir(path: &Path, recursive: bool) -> Result<DirectoryListing, ToolError> {
let mut result = DirectoryListing {
entries: Vec::new(),
total_entries: 0,
truncated: false,
};
let mut backlog = vec![path.to_path_buf()];
while let Some(dir) = backlog.pop() {
if !dir.is_dir() {
return Result::Err(ToolError::DirectoryEntryNotFound {
path: path.to_string_lossy().to_string(),
kind: DirectoryEntryKind::Directory,
suggestion: "Provide a directory to the list_dir tool.".to_string(),
});
}
let paths = match fs::read_dir(dir) {
Ok(paths) => paths,
Err(err) if err.kind() == ErrorKind::PermissionDenied => {
continue;
}
Err(err) => return Err(io_error_to_tool_error(err, path)),
};
for entry in paths.flatten() {
if entry.path().is_dir() {
if recursive {
backlog.push(entry.path());
}
result.entries.push(DirectoryEntry {
path: entry.path().to_string_lossy().to_string(),
kind: DirectoryEntryKind::Directory,
});
} else {
result.entries.push(DirectoryEntry {
path: entry.path().to_string_lossy().to_string(),
kind: DirectoryEntryKind::File,
});
}
}
}
result.total_entries = result.entries.len();
Ok(result)
}
fn allowed_canonical_path(path_string: &Path, base_dir: &Path) -> Result<PathBuf, ToolError> {
let canonical_base = base_dir
.canonicalize()
.map_err(|err| io_error_to_tool_error(err, path_string))?;
let path = base_dir
.join(path_string)
.canonicalize()
.map_err(|err| io_error_to_tool_error(err, path_string))?;
if path.starts_with(canonical_base) {
Ok(path)
} else {
Err(ToolError::PathTraversal {
requested: path_string.to_string_lossy().to_string(),
base: base_dir.to_string_lossy().to_string(),
})
}
}
fn io_error_to_tool_error(err: std::io::Error, path: &Path) -> ToolError {
let path_string = path.to_string_lossy().to_string();
match err.kind() {
ErrorKind::NotFound => ToolError::DirectoryEntryNotFound {
path: path_string,
kind: DirectoryEntryKind::File,
suggestion: "Use list_dir tool to explore available files.".to_string(),
},
ErrorKind::PermissionDenied => ToolError::PermissionDenied { path: path_string },
_ => ToolError::Io {
path: Some(path_string),
source: err,
},
}
}
#[derive(Debug)]
pub enum ToolError {
DirectoryEntryNotFound {
path: String,
kind: DirectoryEntryKind,
suggestion: String,
},
InvalidArgs {
argument: &'static str,
reason: String,
},
NotAFile { path: String, suggestion: String },
PathTraversal { requested: String, base: String },
PermissionDenied { path: String },
ResourceNotFound {
resource_type: ResourceType,
identifier: String,
},
Io {
path: Option<String>,
source: std::io::Error,
},
Mcp { kind: String, info: String }, }
impl std::fmt::Display for ToolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ToolError::DirectoryEntryNotFound {
path,
kind,
suggestion,
} => {
write!(f, "Error: {} '{}' not found. {}", kind, path, suggestion)
}
ToolError::InvalidArgs { argument, reason } => {
write!(
f,
"Error: Invalid value for argument '{}': {}",
argument, reason
)
}
ToolError::NotAFile { path, suggestion } => {
write!(f, "Error: '{}' is not a file. {}", path, suggestion)
}
ToolError::PathTraversal { requested, base } => {
write!(
f,
"Error: Path '{}' is outside the allowed directory '{}'. \
Only paths within the project root are accessible.",
requested, base
)
}
ToolError::PermissionDenied { path } => {
write!(
f,
"Error: Permission denied when accessing '{}'. \
This file/directory requires elevated permissions. \
You cannot access this resource. Continue with available data \
or ask the user to check permissions.",
path
)
}
ToolError::ResourceNotFound {
resource_type,
identifier,
} => {
write!(
f,
"Error: {} with identifier '{}' not found. \
Check the conversation history for valid {} IDs.",
resource_type, identifier, resource_type
)
}
ToolError::Io { path, source } => {
if let Some(p) = path {
write!(f, "Error reading '{}': {}", p, source)
} else {
write!(f, "Error: {}", source)
}
}
ToolError::Mcp { kind, info } => {
write!(f, "Error {} accessing MCP with info '{}'.", kind, info)
}
}
}
}
impl std::error::Error for ToolError {}
#[derive(Debug, Clone)]
pub enum ResourceType {
Observation,
}
impl std::fmt::Display for ResourceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResourceType::Observation => {
write!(f, "Observation")
}
}
}
}
pub async fn get_mcp_tools(
tools: &BTreeMap<String, config::McpTool>,
) -> Vec<(RunningService<RoleClient, ClientInfo>, Vec<RmcpTool>)> {
let mut result = Vec::new();
for (name, mcp_tool) in tools {
if let Ok((peer, tools)) = connect_and_fetch_tools(mcp_tool).await {
result.push((peer, tools));
} else {
}
}
result
}
async fn connect_and_fetch_tools(
mcp_tool: &config::McpTool,
) -> Result<(RunningService<RoleClient, ClientInfo>, Vec<RmcpTool>), anyhow::Error> {
let client = create_client(mcp_tool).await?;
let tools = client.list_tools(Default::default()).await?.tools;
Ok((client, tools))
}
async fn create_client(
mcp_tool: &config::McpTool,
) -> Result<RunningService<RoleClient, ClientInfo>, ClientInitializeError> {
let transport = StreamableHttpClientTransport::from_uri(mcp_tool.url.clone());
let client_info_impl = Implementation::new(
env!("CARGO_PKG_NAME").to_string(),
env!("CARGO_PKG_VERSION").to_string(),
);
let client_info = ClientInfo::new(ClientCapabilities::default(), client_info_impl);
client_info.serve(transport).await.inspect_err(|_e| {
})
}
#[tokio::test]
async fn test_list_dir() {
let base_dir = std::env::current_dir().expect("Cannot get current directory");
let ld = ListDir::new(&base_dir, Some(100));
let result = ld
.call(
&mut ToolContext::default(),
ListDirArgs {
path: PathBuf::from(""),
start: None,
},
)
.await
.unwrap();
println!("Result: {:?}", result.total_entries);
}
#[tokio::test]
async fn test_read_file() {
let base_dir = std::env::current_dir().expect("Cannot get current directory");
let ld = ReadFile::new(&base_dir);
let result = ld
.call(
&mut ToolContext::default(),
ReadFileArgs {
path: PathBuf::from("src/main.rs"),
start: Some(1),
end: Some(14),
},
)
.await
.unwrap();
println!("Result: {:?}", result);
}
#[tokio::test]
async fn test_search_text() {
let base_dir = std::env::current_dir().expect("Cannot get current directory");
let st = SearchText::new(&base_dir, 100);
let result = st
.call(
&mut ToolContext::default(),
SearchTextArgs {
path: PathBuf::from("src"),
text: "unwrap()".to_string(),
start: None,
},
)
.await
.unwrap();
println!("Result: {:?}", result);
}
#[tokio::test]
async fn test_web_search() {
let config = config::load_config().expect("Should be able to load config.");
let ws = WebSearch::new(&config::WebSearch {
provider: WebSearchProvider::Tavily,
url: config
.tools
.web_search
.expect("Should have websearch configured for tests")
.url,
})
.await
.expect("Should be able to instantiate web search");
let result = ws
.call(
&mut ToolContext::default(),
WebSearchArgs {
query: "Rust latest version".to_string(),
max_results: 5,
},
)
.await
.unwrap();
println!("Result: {:?}", result);
}