#[cfg(feature = "mcp")]
mod mcp;
#[cfg(feature = "mcp")]
pub use mcp::McpClient;
use std::collections::BTreeMap;
use std::fmt::Debug;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::{
Disposition, Effect, EffectDescriptor, EffectError, Recovery, RetryPolicy, Sensitivity, Trust,
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ToolId {
pub server: String,
pub tool: String,
}
impl ToolId {
pub fn new(server: impl Into<String>, tool: impl Into<String>) -> Self {
Self {
server: server.into(),
tool: tool.into(),
}
}
}
impl std::fmt::Display for ToolId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.server, self.tool)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolSafety {
pub mutates: bool,
pub recovery: Recovery,
pub max_sensitivity: Sensitivity,
pub output_sensitivity: Sensitivity,
pub retry: RetryPolicy,
}
impl Default for ToolSafety {
fn default() -> Self {
Self {
mutates: true,
recovery: Recovery::RequiresOperator,
max_sensitivity: Sensitivity::Public,
output_sensitivity: Sensitivity::Public,
retry: RetryPolicy::never(),
}
}
}
impl ToolSafety {
#[must_use]
pub fn read_only() -> Self {
Self {
mutates: false,
recovery: Recovery::Retry,
..Self::default()
}
}
#[must_use]
pub fn recovery(mut self, r: Recovery) -> Self {
self.recovery = r;
self
}
#[must_use]
pub const fn max_sensitivity(mut self, s: Sensitivity) -> Self {
self.max_sensitivity = s;
self
}
#[must_use]
pub const fn output_sensitivity(mut self, s: Sensitivity) -> Self {
self.output_sensitivity = s;
self
}
#[must_use]
pub fn retry(mut self, r: RetryPolicy) -> Self {
self.retry = r;
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Advertised {
pub read_only: Option<bool>,
pub destructive: Option<bool>,
pub idempotent: Option<bool>,
}
impl Advertised {
#[must_use]
pub fn overclaims(&self, safety: &ToolSafety) -> bool {
(self.read_only == Some(true) && safety.mutates)
|| (self.idempotent == Some(true)
&& safety.mutates
&& matches!(safety.recovery, Recovery::RequiresOperator))
}
}
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("could not reach '{tool}': {detail}")]
Unreachable { tool: ToolId, detail: String },
#[error("'{tool}' refused the request: {detail}")]
Refused { tool: ToolId, detail: String },
#[error("'{tool}' did not answer in time: {detail}")]
TimedOut { tool: ToolId, detail: String },
#[error("'{tool}' reported an error: {detail}")]
ToolFailed { tool: ToolId, detail: String },
#[error("'{tool}' returned a malformed response: {detail}")]
Malformed { tool: ToolId, detail: String },
}
impl ToolError {
#[must_use]
pub const fn disposition(&self) -> Disposition {
match self {
Self::Unreachable { .. } | Self::Refused { .. } => Disposition::DidNotHappen,
Self::TimedOut { .. } => Disposition::InDoubt,
Self::ToolFailed { .. } | Self::Malformed { .. } => Disposition::Landed,
}
}
}
#[async_trait]
pub trait ToolClient: Send + Sync + Debug {
async fn call(
&self,
tool: &ToolId,
arguments: &Value,
provenance: Option<&crate::core::Provenance>,
) -> Result<Value, ToolError>;
}
#[derive(Debug, Default, Clone)]
pub struct ToolCatalog {
entries: BTreeMap<ToolId, (ToolSafety, Advertised)>,
}
impl ToolCatalog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn allow(mut self, id: ToolId, safety: ToolSafety) -> Self {
self.entries.insert(id, (safety, Advertised::default()));
self
}
#[must_use]
pub fn observed(mut self, id: &ToolId, advertised: Advertised) -> Self {
if let Some(entry) = self.entries.get_mut(id) {
entry.1 = advertised;
}
self
}
#[must_use]
pub fn safety(&self, id: &ToolId) -> Option<&ToolSafety> {
self.entries.get(id).map(|(s, _)| s)
}
#[must_use]
pub fn advertised(&self, id: &ToolId) -> Option<&Advertised> {
self.entries.get(id).map(|(_, a)| a)
}
pub fn overclaiming(&self) -> impl Iterator<Item = &ToolId> {
self.entries
.iter()
.filter(|(_, (safety, adv))| adv.overclaims(safety))
.map(|(id, _)| id)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug)]
pub struct ToolCall {
id: ToolId,
arguments: Value,
safety: ToolSafety,
client: std::sync::Arc<dyn ToolClient>,
provenance: Option<crate::core::Provenance>,
}
impl ToolCall {
pub fn prepare(
catalog: &ToolCatalog,
client: std::sync::Arc<dyn ToolClient>,
id: ToolId,
arguments: Value,
) -> Result<Self, ToolError> {
let Some(safety) = catalog.safety(&id) else {
return Err(ToolError::Unreachable {
detail: "this tool is not in the catalogue; a tool nobody declared is a \
tool nobody has reasoned about"
.into(),
tool: id,
});
};
Ok(Self {
safety: safety.clone(),
id,
arguments,
client,
provenance: None,
})
}
}
#[async_trait]
impl Effect for ToolCall {
type Output = Value;
fn descriptor(&self) -> EffectDescriptor {
EffectDescriptor::new(
"mcp.tools/call",
serde_json::json!({
"server": self.id.server,
"tool": self.id.tool,
"arguments": self.arguments,
}),
)
}
fn mutates(&self) -> bool {
self.safety.mutates
}
fn recovery(&self) -> Recovery {
self.safety.recovery.clone()
}
fn retry(&self) -> RetryPolicy {
self.safety.retry
}
fn max_sensitivity(&self) -> Sensitivity {
self.safety.max_sensitivity
}
fn output_sensitivity(&self) -> Sensitivity {
self.safety.output_sensitivity
}
fn trust(&self) -> Trust {
Trust::Untrusted
}
fn attach(&mut self, provenance: &crate::core::Provenance) {
self.provenance = Some(provenance.clone());
}
async fn perform(&self) -> Result<Value, EffectError> {
self.client
.call(&self.id, &self.arguments, self.provenance.as_ref())
.await
.map_err(|e| {
let detail = e.to_string();
match e.disposition() {
Disposition::DidNotHappen => EffectError::Rejected(detail),
Disposition::InDoubt => EffectError::Interrupted {
driver: self.id.to_string(),
detail,
},
Disposition::Landed => EffectError::Performed(detail),
}
})
}
}