use futures::FutureExt;
use indexmap::IndexMap;
use std::any::Any;
use std::collections::HashSet;
use std::fmt;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use super::{
SharedState, Tool, ToolContext, ToolError, ToolNamespace, ToolResult, ToolSchema, ToolSource,
};
use crate::message::ToolCall;
use crate::run::RunContext;
fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
return (*s).chars().take(500).collect();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.chars().take(500).collect();
}
"unknown panic".into()
}
#[derive(Clone, Default)]
pub struct ToolRegistry {
tools: IndexMap<String, RegisteredTool>,
}
#[derive(Clone)]
struct RegisteredTool {
tool: Arc<dyn Tool>,
source: Option<ToolSource>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: IndexMap::new(),
}
}
pub fn register(&mut self, tool: impl Tool + 'static) -> &mut Self {
self.tools.insert(
tool.schema().name,
RegisteredTool {
tool: Arc::new(tool),
source: None,
},
);
self
}
pub fn register_with_source(
&mut self,
tool: impl Tool + 'static,
source: ToolSource,
) -> Result<&mut Self, RegistryError> {
let name = tool.schema().name;
if source.display_name != name {
return Err(RegistryError::SourceNameMismatch {
schema_name: name,
source_display_name: source.display_name,
});
}
if let Some(existing) = self.tools.get(&name) {
let existing_namespace = entry_namespace(existing);
if existing_namespace != source.namespace {
return Err(RegistryError::NameCollision {
name,
existing_namespace,
new_namespace: source.namespace,
});
}
}
self.tools.insert(
name,
RegisteredTool {
tool: Arc::new(tool),
source: Some(source),
},
);
Ok(self)
}
pub fn names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
pub fn remove(&mut self, name: &str) -> bool {
self.tools.shift_remove(name).is_some()
}
pub fn source(&self, display_name: &str) -> Option<&ToolSource> {
self.tools
.get(display_name)
.and_then(|entry| entry.source.as_ref())
}
pub fn names_in_namespace(&self, namespace: &ToolNamespace) -> Vec<String> {
self.tools
.iter()
.filter(|(_, entry)| entry_matches_namespace(entry, namespace))
.map(|(name, _)| name.clone())
.collect()
}
pub fn remove_namespace(&mut self, namespace: &ToolNamespace) -> Vec<String> {
let mut removed = Vec::new();
self.tools.retain(|name, entry| {
if entry_matches_namespace(entry, namespace) {
removed.push(name.clone());
false
} else {
true
}
});
removed
}
pub fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) -> Vec<String> {
let mut removed = Vec::new();
self.tools.retain(|name, _| {
if keep(name) {
true
} else {
removed.push(name.clone());
false
}
});
removed
}
pub fn schemas(&self) -> Vec<ToolSchema> {
self.tools.values().map(schema_with_source).collect()
}
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools.get(name).map(|entry| entry.tool.as_ref())
}
pub async fn call(
&self,
call: &ToolCall,
run: &RunContext,
state: &SharedState,
) -> Result<ToolResult, RegistryError> {
let Some(entry) = self.tools.get(&call.name) else {
return Err(RegistryError::NotFound(call.name.clone()));
};
let args = match serde_json::from_str(&call.arguments) {
Ok(value) => value,
Err(e) => return Err(RegistryError::InvalidArguments(e.to_string())),
};
let context = ToolContext::new(run, state, &call.id, &call.name);
let result = AssertUnwindSafe(entry.tool.call(args, context))
.catch_unwind()
.await
.map_err(|payload| {
let message = panic_message(payload.as_ref());
RegistryError::Execution {
name: call.name.clone(),
source: ToolError::Execution(format!("panicked: {message}")),
}
})?;
result
.map_err(|e| match e {
ToolError::InvalidArguments(msg) => RegistryError::InvalidArguments(msg),
other => RegistryError::Execution {
name: call.name.clone(),
source: other,
},
})
.map(|result| match result {
ToolResult::Effect(request) => ToolResult::Effect(
request.with_source_if_missing(call.id.clone(), call.name.clone()),
),
other => other,
})
}
pub async fn call_named(
&self,
name: impl Into<String>,
arguments: impl Into<String>,
run: &RunContext,
state: &SharedState,
) -> Result<ToolResult, RegistryError> {
let name = name.into();
let call = ToolCall {
id: format!("call-{name}"),
name,
arguments: arguments.into(),
};
self.call(&call, run, state).await
}
pub fn subset(&self, names: &[&str]) -> Result<ToolRegistry, MissingTools> {
let wanted: HashSet<&str> = names.iter().copied().collect();
let mut tools = IndexMap::new();
let mut found = HashSet::new();
for (name, entry) in &self.tools {
if wanted.contains(name.as_str()) {
found.insert(name.clone());
tools.insert(name.clone(), entry.clone());
}
}
let missing: Vec<String> = names
.iter()
.filter(|n| !found.contains(**n))
.map(|n| (*n).to_string())
.collect();
if missing.is_empty() {
Ok(ToolRegistry { tools })
} else {
Err(MissingTools { names: missing })
}
}
pub fn subset_by_namespace(
&self,
namespace: &ToolNamespace,
) -> Result<ToolRegistry, RegistryError> {
let mut tools = IndexMap::new();
for (name, entry) in &self.tools {
if entry_matches_namespace(entry, namespace) {
tools.insert(name.clone(), entry.clone());
}
}
Ok(ToolRegistry { tools })
}
}
impl fmt::Debug for ToolRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.names()).finish()
}
}
impl<T> Extend<T> for ToolRegistry
where
T: Tool + 'static,
{
fn extend<I>(&mut self, iter: I)
where
I: IntoIterator<Item = T>,
{
for tool in iter {
self.register(tool);
}
}
}
impl<T> FromIterator<T> for ToolRegistry
where
T: Tool + 'static,
{
fn from_iter<I>(iter: I) -> Self
where
I: IntoIterator<Item = T>,
{
let mut registry = Self::new();
registry.extend(iter);
registry
}
}
fn entry_namespace(entry: &RegisteredTool) -> ToolNamespace {
entry
.source
.as_ref()
.map(|source| source.namespace.clone())
.unwrap_or_else(ToolNamespace::local)
}
fn entry_matches_namespace(entry: &RegisteredTool, namespace: &ToolNamespace) -> bool {
entry_namespace(entry) == *namespace
}
fn schema_with_source(entry: &RegisteredTool) -> ToolSchema {
let mut schema = entry.tool.schema();
if let Some(source) = &entry.source
&& let Ok(value) = serde_json::to_value(source)
{
schema.metadata.insert("tool_source".to_string(), value);
}
schema
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("tools not found in registry: {}", self.names.join(", "))]
pub struct MissingTools {
names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RegistryError {
#[error("tool not found: {0}")]
NotFound(String),
#[error("invalid arguments: {0}")]
InvalidArguments(String),
#[error("tool error: {name} failed: {source}")]
Execution {
name: String,
#[source]
source: ToolError,
},
#[error(
"tool name collision: {name} already belongs to namespace {existing_namespace}, cannot register from namespace {new_namespace}"
)]
NameCollision {
name: String,
existing_namespace: ToolNamespace,
new_namespace: ToolNamespace,
},
#[error(
"tool source display name mismatch: schema name {schema_name}, source display name {source_display_name}"
)]
SourceNameMismatch {
schema_name: String,
source_display_name: String,
},
}
impl MissingTools {
pub fn names(&self) -> &[String] {
&self.names
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::{ToolError, ToolOutput, ToolSource, ToolTrustLevel};
use std::error::Error;
use std::sync::atomic::{AtomicUsize, Ordering};
struct FakeTool {
name: &'static str,
output: &'static str,
fail: bool,
}
#[async_trait::async_trait]
impl Tool for FakeTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new(self.name, self.output, serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
if self.fail {
Err(ToolError::Execution("boom".into()))
} else {
Ok(ToolOutput::text(self.output).into())
}
}
}
struct CountingTool {
name: &'static str,
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Tool for CountingTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new(self.name, "counts calls", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Ok(ToolOutput::text("ok").into())
}
}
fn echo(name: &'static str) -> FakeTool {
FakeTool {
name,
output: name,
fail: false,
}
}
fn registry() -> ToolRegistry {
let mut r = ToolRegistry::new();
r.register(echo("search"))
.register(echo("calculator"))
.register(echo("search"));
r
}
fn call(name: &str, arguments: &str) -> ToolCall {
ToolCall {
id: format!("call-{name}"),
name: name.into(),
arguments: arguments.into(),
}
}
async fn call_registry(
registry: &ToolRegistry,
name: &str,
arguments: &str,
state: &SharedState,
) -> Result<ToolResult, RegistryError> {
registry
.call(&call(name, arguments), &RunContext::new("test-run"), state)
.await
}
#[test]
fn names_in_registration_order_dedup() {
assert_eq!(registry().names(), vec!["search", "calculator"]);
}
#[test]
fn schemas_in_registration_order() {
let schemas = registry().schemas();
assert_eq!(schemas.len(), 2);
assert_eq!(schemas[0].name, "search");
assert_eq!(schemas[1].name, "calculator");
}
#[test]
fn from_iter_and_extend_keep_registry_semantics() {
let mut registry: ToolRegistry = [echo("a"), echo("b"), echo("a")].into_iter().collect();
assert_eq!(registry.names(), vec!["a", "b"]);
assert_eq!(registry.schemas()[0].description, "a");
registry.extend([echo("c")]);
assert_eq!(registry.names(), vec!["a", "b", "c"]);
}
#[tokio::test]
async fn register_duplicate_replaces() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "a",
output: "first",
fail: false,
})
.register(FakeTool {
name: "a",
output: "second",
fail: false,
});
assert_eq!(r.names(), vec!["a"]);
assert_eq!(r.schemas()[0].description, "second");
assert_eq!(
call_registry(&r, "a", "{}", &SharedState::new())
.await
.unwrap(),
"second"
);
}
#[tokio::test]
async fn get_returns_tool_with_error_semantics() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "a",
output: "",
fail: true,
});
let tool = r.get("a").expect("registered");
let state = SharedState::new();
let run = RunContext::new("test-run");
let context = ToolContext {
run: &run,
state: &state,
tool_call_id: "call-a",
tool_name: "a",
};
let result = tool.call(serde_json::json!({}), context).await;
assert!(matches!(result, Err(ToolError::Execution(_))));
assert!(r.get("nope").is_none());
}
#[tokio::test]
async fn call_succeeds() {
assert_eq!(
registry()
.call(
&call("calculator", "{}"),
&RunContext::new("test-run"),
&SharedState::new()
)
.await
.unwrap(),
"calculator"
);
}
#[tokio::test]
async fn call_unknown_tool_returns_not_found() {
let err = registry()
.call(
&call("nope", "{}"),
&RunContext::new("test-run"),
&SharedState::new(),
)
.await
.unwrap_err();
assert!(matches!(&err, RegistryError::NotFound(name) if name == "nope"));
assert_eq!(err.to_string(), "tool not found: nope");
}
#[tokio::test]
async fn call_invalid_json_returns_invalid_arguments() {
let err = registry()
.call(
&call("calculator", "not-json"),
&RunContext::new("test-run"),
&SharedState::new(),
)
.await
.unwrap_err();
assert!(matches!(err, RegistryError::InvalidArguments(_)));
assert!(err.to_string().starts_with("invalid arguments:"));
}
#[tokio::test]
async fn call_structural_error_returns_invalid_arguments_not_execution() {
struct StrictTool;
#[async_trait::async_trait]
impl Tool for StrictTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new(
"strict",
"requires a",
serde_json::json!({
"type": "object",
"properties": { "a": { "type": "integer" } },
"required": ["a"],
}),
)
}
async fn call(
&self,
arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
let a = arguments
.get("a")
.ok_or_else(|| ToolError::InvalidArguments("missing field `a`".into()))?;
Ok(ToolOutput::text(a.to_string()).into())
}
}
let mut r = ToolRegistry::new();
r.register(StrictTool);
let err = r
.call(
&call("strict", r#"{"b":1}"#),
&RunContext::new("test-run"),
&SharedState::new(),
)
.await
.unwrap_err();
assert!(matches!(&err, RegistryError::InvalidArguments(msg) if msg == "missing field `a`"));
assert_eq!(err.to_string(), "invalid arguments: missing field `a`");
}
#[tokio::test]
async fn call_execution_error_returns_execution_with_source() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "broken",
output: "",
fail: true,
});
let err = r
.call(
&call("broken", "{}"),
&RunContext::new("test-run"),
&SharedState::new(),
)
.await
.unwrap_err();
assert_eq!(
err.to_string(),
"tool error: broken failed: execution failed: boom"
);
assert!(matches!(err.source(), Some(e) if e.to_string() == "execution failed: boom"));
}
#[tokio::test]
async fn tool_panic_is_captured_as_execution_error() {
struct PanickingTool;
#[async_trait::async_trait]
impl Tool for PanickingTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new("panic", "panics", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
panic!("boom")
}
}
let mut r = ToolRegistry::new();
r.register(PanickingTool);
let err = r
.call(
&call("panic", "{}"),
&RunContext::new("test-run"),
&SharedState::new(),
)
.await
.unwrap_err();
assert!(
matches!(err, RegistryError::Execution { name, source: ToolError::Execution(msg) }
if name == "panic" && msg.contains("panicked") && msg.contains("boom"))
);
}
#[test]
fn retain_removes_by_prefix_in_registration_order() {
let mut r = ToolRegistry::new();
r.register(echo("fs__read"))
.register(echo("fs__write"))
.register(echo("calc"));
let removed = r.retain(|name| !name.starts_with("fs__"));
assert_eq!(removed, ["fs__read", "fs__write"]);
assert_eq!(r.names(), ["calc"]);
}
#[test]
fn retain_keep_all_returns_empty() {
let mut r = registry();
assert!(r.retain(|_| true).is_empty());
assert_eq!(r.names(), ["search", "calculator"]);
}
#[test]
fn register_with_source_tracks_namespace_and_metadata() {
let mut r = ToolRegistry::new();
let namespace = ToolNamespace::mcp_server("filesystem");
let source = ToolSource::new(namespace.clone(), "read_file", "filesystem__read_file")
.with_trust(ToolTrustLevel::External);
r.register_with_source(echo("filesystem__read_file"), source.clone())
.unwrap();
assert_eq!(r.source("filesystem__read_file"), Some(&source));
assert_eq!(
r.names_in_namespace(&namespace),
vec!["filesystem__read_file"]
);
let schema = r.schemas().remove(0);
assert_eq!(
schema.metadata["tool_source"]["namespace"]["id"],
serde_json::json!("filesystem")
);
assert_eq!(
schema.metadata["tool_source"]["raw_name"],
serde_json::json!("read_file")
);
}
#[test]
fn register_with_source_replaces_same_namespace_but_rejects_cross_namespace_collision() {
let mut r = ToolRegistry::new();
let first = ToolSource::new(ToolNamespace::mcp_server("one"), "search", "server__search");
let second_same =
ToolSource::new(ToolNamespace::mcp_server("one"), "search", "server__search");
let second_other =
ToolSource::new(ToolNamespace::mcp_server("two"), "search", "server__search");
r.register_with_source(echo("server__search"), first)
.unwrap();
r.register_with_source(
FakeTool {
name: "server__search",
output: "replacement",
fail: false,
},
second_same,
)
.unwrap();
let err = r
.register_with_source(echo("server__search"), second_other)
.unwrap_err();
assert!(matches!(
err,
RegistryError::NameCollision {
name,
existing_namespace,
new_namespace,
} if name == "server__search"
&& existing_namespace == ToolNamespace::mcp_server("one")
&& new_namespace == ToolNamespace::mcp_server("two")
));
}
#[test]
fn source_display_name_must_match_schema_name() {
let mut r = ToolRegistry::new();
let err = r
.register_with_source(
echo("actual"),
ToolSource::new(ToolNamespace::mcp_server("fs"), "raw", "different"),
)
.unwrap_err();
assert!(matches!(
err,
RegistryError::SourceNameMismatch {
schema_name,
source_display_name,
} if schema_name == "actual" && source_display_name == "different"
));
}
#[test]
fn remove_namespace_bulk_unloads_tools() {
let mut r = ToolRegistry::new();
let fs = ToolNamespace::mcp_server("fs");
let db = ToolNamespace::mcp_server("db");
r.register_with_source(
echo("fs__read"),
ToolSource::new(fs.clone(), "read", "fs__read"),
)
.unwrap()
.register_with_source(
echo("fs__write"),
ToolSource::new(fs.clone(), "write", "fs__write"),
)
.unwrap()
.register_with_source(
echo("db__query"),
ToolSource::new(db.clone(), "query", "db__query"),
)
.unwrap();
let sub = r.subset_by_namespace(&fs).unwrap();
assert_eq!(sub.names(), ["fs__read", "fs__write"]);
let removed = r.remove_namespace(&fs);
assert_eq!(removed, ["fs__read", "fs__write"]);
assert_eq!(r.names(), ["db__query"]);
assert_eq!(r.names_in_namespace(&db), ["db__query"]);
}
#[test]
fn subset_keeps_registration_order() {
let sub = registry().subset(&["calculator", "search"]).unwrap();
assert_eq!(sub.names(), vec!["search", "calculator"]);
}
#[tokio::test]
async fn subset_duplicate_name_takes_latest() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "a",
output: "first",
fail: false,
})
.register(FakeTool {
name: "a",
output: "second",
fail: false,
});
let sub = r.subset(&["a"]).unwrap();
assert_eq!(sub.names(), vec!["a"]);
assert_eq!(
call_registry(&sub, "a", "{}", &SharedState::new())
.await
.unwrap(),
"second"
);
}
#[test]
fn subset_missing_names_error_with_list() {
let err = registry()
.subset(&["search", "nope", "calculator", "also-nope"])
.unwrap_err();
assert_eq!(err.names(), &["nope", "also-nope"]);
}
#[tokio::test]
async fn subset_shares_tool_instances() {
let calls = Arc::new(AtomicUsize::new(0));
let mut r = ToolRegistry::new();
r.register(CountingTool {
name: "counter",
calls: calls.clone(),
});
let sub = r.subset(&["counter"]).unwrap();
call_registry(&r, "counter", "{}", &SharedState::new())
.await
.unwrap();
call_registry(&sub, "counter", "{}", &SharedState::new())
.await
.unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn clone_shares_tool_instances() {
let calls = Arc::new(AtomicUsize::new(0));
let mut r = ToolRegistry::new();
r.register(CountingTool {
name: "counter",
calls: calls.clone(),
});
let r2 = r.clone();
call_registry(&r, "counter", "{}", &SharedState::new())
.await
.unwrap();
call_registry(&r2, "counter", "{}", &SharedState::new())
.await
.unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn call_passes_shared_state_to_tool() {
struct StateTool;
#[async_trait::async_trait]
impl Tool for StateTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new(
"state_tool",
"read and write shared state",
serde_json::json!({}),
)
}
async fn call(
&self,
_arguments: serde_json::Value,
context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
let state = context.state;
state.with_mut::<usize>(|n| *n += 1);
Ok(ToolOutput::text(format!("count={}", state.get::<usize>().unwrap_or(0))).into())
}
}
let state = SharedState::new();
state.insert(0usize);
let mut r = ToolRegistry::new();
r.register(StateTool);
assert_eq!(
call_registry(&r, "state_tool", "{}", &state).await.unwrap(),
"count=1"
);
assert_eq!(
call_registry(&r, "state_tool", "{}", &state).await.unwrap(),
"count=2"
);
}
}