use std::{
collections::BTreeMap,
path::{Path, PathBuf},
time::Duration,
};
use mentra::tool::ToolSideEffectLevel;
use serde::Deserialize;
use serde_json::Value;
use thiserror::Error;
use crate::{context::ContextScope, expand::expand};
pub const DEFAULT_WORKSPACE_TOOLS_FILE: &str = ".basis/tools.json";
pub const DEFAULT_GLOBAL_TOOLS_FILE: &str = "tools.json";
pub const TOOLS_SCHEMA_VERSION: u32 = 1;
pub const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
const MAX_NAME_LENGTH: usize = 64;
const MCP_PREFIX: &str = "mcp__";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SideEffect {
#[default]
Process,
External,
}
impl SideEffect {
pub const fn level(self) -> ToolSideEffectLevel {
match self {
Self::Process => ToolSideEffectLevel::Process,
Self::External => ToolSideEffectLevel::External,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct DeclaredToolSpec {
pub name: String,
pub description: String,
pub input_schema: Value,
pub command: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: Vec<(String, String)>,
pub timeout_ms: Option<u64>,
pub side_effect: SideEffect,
}
impl std::fmt::Debug for DeclaredToolSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeclaredToolSpec")
.field("name", &self.name)
.field("command", &self.command)
.field("cwd", &self.cwd)
.field(
"env",
&self
.env
.iter()
.map(|(key, _)| (key, "<redacted>"))
.collect::<BTreeMap<_, _>>(),
)
.field("timeout_ms", &self.timeout_ms)
.field("side_effect", &self.side_effect)
.finish_non_exhaustive()
}
}
impl DeclaredToolSpec {
pub fn timeout(&self) -> Duration {
self.timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_TOOL_TIMEOUT)
}
pub fn working_directory(&self, workspace: &Path) -> PathBuf {
self.cwd
.as_ref()
.map_or_else(|| workspace.to_path_buf(), |cwd| workspace.join(cwd))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolsConfig {
pub workspace_file: PathBuf,
pub global_dir: Option<PathBuf>,
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
workspace_file: PathBuf::from(DEFAULT_WORKSPACE_TOOLS_FILE),
global_dir: crate::context::ContextConfig::default().global_dir,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolsSource {
pub path: PathBuf,
pub scope: ContextScope,
pub tools: Vec<DeclaredToolSpec>,
}
#[derive(Debug, Error)]
pub enum DeclaredToolError {
#[error("failed to read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is not a valid tool manifest: {problem} at line {line}, column {column}")]
Parse {
path: PathBuf,
problem: &'static str,
line: usize,
column: usize,
},
#[error("{path} has no `tools` object")]
NoTools { path: PathBuf },
#[error("{path} declares no `schema`; this basis understands {TOOLS_SCHEMA_VERSION}")]
NoSchema { path: PathBuf },
#[error(
"{path} declares tools schema {schema}, but this basis understands {TOOLS_SCHEMA_VERSION}"
)]
UnsupportedSchema { path: PathBuf, schema: u32 },
#[error("{path}: tool `{name}` {reason}")]
Invalid {
path: PathBuf,
name: String,
reason: String,
},
#[error("{path}: tool `{name}` cannot be registered because {reason}")]
NameTaken {
path: PathBuf,
name: String,
reason: String,
},
}
pub fn discover(
workspace: &Path,
config: &ToolsConfig,
) -> Result<Vec<ToolsSource>, DeclaredToolError> {
let mut sources = Vec::new();
let workspace_file = workspace.join(&config.workspace_file);
if workspace_file.is_file() {
sources.push(read(workspace_file, ContextScope::Workspace)?);
}
if let Some(global) = &config.global_dir {
let global_file = global.join(DEFAULT_GLOBAL_TOOLS_FILE);
if global_file.is_file()
&& !sources
.iter()
.any(|source| crate::paths::same_dir(&source.path, &global_file))
{
sources.push(read(global_file, ContextScope::Global)?);
}
}
Ok(sources)
}
pub fn load(
workspace: &Path,
config: &ToolsConfig,
) -> Result<Vec<DeclaredToolSpec>, DeclaredToolError> {
Ok(layer(&discover(workspace, config)?)
.into_iter()
.map(|(_, spec)| spec)
.collect())
}
pub(super) fn layer(sources: &[ToolsSource]) -> Vec<(PathBuf, DeclaredToolSpec)> {
let mut kept: Vec<(PathBuf, DeclaredToolSpec)> = Vec::new();
for source in sources {
for spec in &source.tools {
if !kept.iter().any(|(_, seen)| seen.name == spec.name) {
kept.push((source.path.clone(), spec.clone()));
}
}
}
kept
}
fn read(path: PathBuf, scope: ContextScope) -> Result<ToolsSource, DeclaredToolError> {
let text = std::fs::read_to_string(&path).map_err(|source| DeclaredToolError::Read {
path: path.clone(),
source,
})?;
let tools = parse(&path, &text, &|name| std::env::var(name).ok())?;
Ok(ToolsSource { path, scope, tools })
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ToolsFile {
schema: Option<u32>,
tools: Option<BTreeMap<String, RawTool>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTool {
description: Option<String>,
input_schema: Option<Value>,
command: Option<Vec<String>>,
cwd: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
timeout_ms: Option<u64>,
#[serde(default)]
side_effect: SideEffect,
}
fn parse(
path: &Path,
text: &str,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Vec<DeclaredToolSpec>, DeclaredToolError> {
let file: ToolsFile =
serde_json::from_str(text).map_err(|source| DeclaredToolError::Parse {
path: path.to_path_buf(),
problem: match source.classify() {
serde_json::error::Category::Syntax => "a syntax error",
serde_json::error::Category::Data => "an unknown key or a value of the wrong type",
serde_json::error::Category::Eof => "an unexpected end of input",
serde_json::error::Category::Io => "a read error",
},
line: source.line(),
column: source.column(),
})?;
match file.schema {
None => {
return Err(DeclaredToolError::NoSchema {
path: path.to_path_buf(),
});
}
Some(schema) if schema != TOOLS_SCHEMA_VERSION => {
return Err(DeclaredToolError::UnsupportedSchema {
path: path.to_path_buf(),
schema,
});
}
Some(_) => {}
}
let Some(entries) = file.tools else {
return Err(DeclaredToolError::NoTools {
path: path.to_path_buf(),
});
};
entries
.into_iter()
.map(|(name, raw)| raw.into_spec(path, name, lookup))
.collect()
}
impl RawTool {
fn into_spec(
self,
path: &Path,
name: String,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<DeclaredToolSpec, DeclaredToolError> {
let invalid = |reason: String| DeclaredToolError::Invalid {
path: path.to_path_buf(),
name: name.clone(),
reason,
};
let expanded = |field: &str, raw: &str| -> Result<String, DeclaredToolError> {
expand(raw, lookup)
.map_err(|reason| invalid(format!("has a `{field}` value that {reason}")))
};
check_name(&name).map_err(invalid)?;
let description = self
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.ok_or_else(|| {
invalid(
"has no `description`, which is the only thing telling the model what it is \
for"
.to_string(),
)
})?
.to_string();
let input_schema = self
.input_schema
.ok_or_else(|| invalid("has no `input_schema`".to_string()))?;
check_schema(&input_schema).map_err(invalid)?;
let command = self
.command
.filter(|command| !command.is_empty())
.ok_or_else(|| invalid("has no `command` to run".to_string()))?
.iter()
.enumerate()
.map(|(index, argument)| expanded(&format!("command[{index}]"), argument))
.collect::<Result<Vec<_>, _>>()?;
if command[0].trim().is_empty() {
return Err(invalid("names an empty program".to_string()));
}
if self.timeout_ms == Some(0) {
return Err(invalid(
"has a `timeout_ms` of 0, which is a deadline that has already passed".to_string(),
));
}
Ok(DeclaredToolSpec {
description,
input_schema,
command,
cwd: self
.cwd
.as_deref()
.map(|cwd| expanded("cwd", cwd))
.transpose()?
.map(PathBuf::from),
env: self
.env
.iter()
.map(|(key, value)| Ok((key.clone(), expanded(&format!("env.{key}"), value)?)))
.collect::<Result<Vec<_>, DeclaredToolError>>()?,
timeout_ms: self.timeout_ms,
side_effect: self.side_effect,
name,
})
}
}
fn check_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("has an empty name".to_string());
}
if name.len() > MAX_NAME_LENGTH {
return Err(format!(
"has a name of {} characters, and a provider takes at most {MAX_NAME_LENGTH}",
name.len()
));
}
if !name
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
{
return Err(
"has a name outside the letters, digits, `_` and `-` a provider accepts".to_string(),
);
}
if name.starts_with(MCP_PREFIX) {
return Err(format!(
"has a name starting with `{MCP_PREFIX}`, which is how mentra names a bridged MCP \
server's tools"
));
}
Ok(())
}
fn check_schema(schema: &Value) -> Result<(), String> {
let Some(object) = schema.as_object() else {
return Err("has an `input_schema` that is not a JSON object".to_string());
};
match object.get("type").and_then(Value::as_str) {
None | Some("object") => Ok(()),
Some(_) => Err(
"has an `input_schema` whose `type` is not `object`, and a tool call's input always \
is one"
.to_string(),
),
}
}
#[cfg(test)]
mod tests;