#[async_trait::async_trait]
pub trait McpTools: Send {
fn handles(&self, name: &str) -> bool;
fn tool_defs(&self) -> Vec<serde_json::Value>;
async fn call(&mut self, leased: &LeasedMcpCall<'_>) -> String;
}
pub struct NoMcp;
#[async_trait::async_trait]
impl McpTools for NoMcp {
fn handles(&self, _name: &str) -> bool {
false
}
fn tool_defs(&self) -> Vec<serde_json::Value> {
Vec::new()
}
async fn call(&mut self, _leased: &LeasedMcpCall<'_>) -> String {
"error: no MCP servers connected".to_string()
}
}
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpEffect {
Read,
Mutating,
}
pub const MCP_READ_VERB_PREFIXES: &[&str] = &[
"get", "list", "read", "search", "find", "fetch", "describe", "query", "show", "lookup",
"view", "count", "stat", "head", "peek", "inspect",
];
#[must_use]
pub fn classify_mcp_effect(tool: &str) -> McpEffect {
let bare = tool.rsplit("__").next().unwrap_or(tool);
let lower = bare.to_ascii_lowercase();
if MCP_READ_VERB_PREFIXES.iter().any(|p| lower.starts_with(p)) {
McpEffect::Read
} else {
McpEffect::Mutating
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpGrant {
HumanApproved,
}
#[derive(Debug)]
pub struct LeasedMcpCall<'a> {
tool: &'a str,
args: &'a Value,
grant: McpGrant,
_seal: (),
}
impl<'a> LeasedMcpCall<'a> {
#[must_use]
pub fn tool(&self) -> &str {
self.tool
}
#[must_use]
pub fn args(&self) -> &Value {
self.args
}
#[must_use]
pub fn grant(&self) -> McpGrant {
self.grant
}
#[must_use]
pub fn server(&self) -> &str {
self.tool.rsplit_once("__").map_or("", |(server, _)| server)
}
}
#[derive(Debug, Clone)]
pub struct LeashDenied {
pub tool: String,
pub reason: String,
}
pub fn leash_mcp_call<'a>(
tool: &'a str,
args: &'a Value,
grant: Option<McpGrant>,
) -> Result<LeasedMcpCall<'a>, LeashDenied> {
match grant {
Some(grant) => Ok(LeasedMcpCall {
tool,
args,
grant,
_seal: (),
}),
None => Err(LeashDenied {
tool: tool.to_string(),
reason: format!("remote tool `{tool}` refused by the call-time leash (no grant)"),
}),
}
}
#[cfg(test)]
mod leash_tests {
use super::*;
#[test]
fn classify_reads_by_verb_prefix_stripping_namespace() {
for read in [
"get_x",
"srv__list_things",
"search",
"readFile",
"srv__describe_pod",
] {
assert_eq!(classify_mcp_effect(read), McpEffect::Read, "{read}");
}
for mut_ in [
"delete_all",
"srv__create_incident",
"exec",
"srv__rm",
"frobnicate",
] {
assert_eq!(classify_mcp_effect(mut_), McpEffect::Mutating, "{mut_}");
}
}
#[test]
fn leash_mints_only_from_a_structural_grant_never_the_name() {
let args = serde_json::json!({});
let leased = leash_mcp_call("srv__delete", &args, Some(McpGrant::HumanApproved))
.expect("a grant mints");
assert_eq!(leased.tool(), "srv__delete");
assert_eq!(leased.args(), &args);
assert_eq!(leased.grant(), McpGrant::HumanApproved);
assert_eq!(leased.server(), "srv");
assert_eq!(
leash_mcp_call("srv__delete", &args, Some(McpGrant::HumanApproved))
.unwrap()
.grant(),
McpGrant::HumanApproved
);
assert!(leash_mcp_call("srv__get_wipe_everything", &args, None).is_err());
}
}