use std::sync::Arc;
use anyhow::{Context as _, Result};
use rmcp::{
ErrorData as McpError, RoleClient, RoleServer, ServerHandler, ServiceExt,
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
},
service::{RequestContext, RunningService},
transport::{ConfigureCommandExt, TokioChildProcess, stdio},
};
use sha2::{Digest, Sha256};
use crate::fence::{
Admission, EffectFence, EffectRequest, FenceError, VectorClock, prepare_effect_fence,
};
fn derive_intent(tool: &str, args: &serde_json::Value) -> String {
fn canonicalize(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(m) => {
let sorted: std::collections::BTreeMap<String, serde_json::Value> = m
.iter()
.map(|(k, val)| (k.clone(), canonicalize(val)))
.collect();
serde_json::to_value(sorted).expect("BTreeMap serialization cannot fail")
}
serde_json::Value::Array(a) => {
serde_json::Value::Array(a.iter().map(canonicalize).collect())
}
other => other.clone(),
}
}
let canon = serde_json::to_string(&canonicalize(args)).expect("Value serialization");
let mut h = Sha256::new();
h.update(tool.as_bytes());
h.update([0u8]);
h.update(canon.as_bytes());
format!("wrap:{}:{}", tool, hex::encode(h.finalize()))
}
mod hex {
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
use std::fmt::Write;
let mut out = String::new();
for b in bytes.as_ref() {
write!(out, "{b:02x}").expect("write to String");
}
out
}
}
#[derive(Clone)]
struct WrapServer {
child: Arc<RunningService<RoleClient, ()>>,
fence: EffectFence,
tools: Arc<Vec<Tool>>,
child_name: Arc<str>,
}
const IN_FLIGHT_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
const IN_FLIGHT_POLL: std::time::Duration = std::time::Duration::from_millis(25);
const HEARTBEAT_DIVISOR: u32 = 3;
impl WrapServer {
async fn await_holder(
&self,
intent: &str,
args: &serde_json::Value,
tool: &str,
) -> Option<CallToolResult> {
let deadline = tokio::time::Instant::now() + IN_FLIGHT_WAIT;
while tokio::time::Instant::now() < deadline {
tokio::time::sleep(IN_FLIGHT_POLL).await;
match prepare_effect_fence(
&self.fence,
EffectRequest {
intent: intent.to_string(),
parent: None,
domain: format!("tool:{tool}"),
tool: tool.to_string(),
args: args.clone(),
read_set: vec![],
agent: "effectfence-wrap".to_string(),
known_clock: VectorClock::new(),
},
) {
Ok(Admission::Replay(cert)) => {
return serde_json::from_value::<CallToolResult>(cert.result.clone()).ok();
}
Err(FenceError::IntentInFlight { .. }) => continue,
Ok(Admission::Fresh(prepared)) => {
self.fence.clear_intent(&prepared.intent);
return None;
}
_ => return None,
}
}
None
}
async fn forward(&self, req: CallToolRequestParams) -> Result<CallToolResult, McpError> {
self.child
.call_tool(req)
.await
.map_err(|e| McpError::internal_error(format!("child server error: {e}"), None))
}
}
impl ServerHandler for WrapServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new(
format!("effectfence-wrap({})", self.child_name),
env!("CARGO_PKG_VERSION"),
);
info.instructions = Some(format!(
"Every tool of the wrapped server `{}` is fenced: an identical duplicate call \
(same tool, same arguments) will NOT re-execute -- it returns the recorded \
result of the first execution. A concurrent identical call waits for the \
one in flight and is handed its result, so the action runs exactly once.",
self.child_name
));
info
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: (*self.tools).clone(),
..Default::default()
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
let intent = derive_intent(&request.name, &args_value);
let tool_name = request.name.to_string();
let intent_for_wait = intent.clone();
let args_for_wait = args_value.clone();
let admission = prepare_effect_fence(
&self.fence,
EffectRequest {
intent,
parent: None,
domain: format!("tool:{}", request.name),
tool: request.name.to_string(),
args: args_value,
read_set: vec![],
agent: "effectfence-wrap".to_string(),
known_clock: VectorClock::new(),
},
);
match admission {
Ok(Admission::Fresh(prepared)) => {
let beater = {
let fence = self.fence.clone();
let intent = prepared.intent.clone();
let every = fence.lease_ttl() / HEARTBEAT_DIVISOR;
tokio::spawn(async move {
loop {
tokio::time::sleep(every).await;
if !fence.heartbeat(&intent) {
break; }
}
})
};
let forwarded = self.forward(request).await;
beater.abort();
let result = match forwarded {
Ok(r) => r,
Err(e) => {
crate::fence::abort_effect(&self.fence, prepared, e.to_string());
return Err(e);
}
};
let stored = serde_json::to_value(&result)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
if result.is_error.unwrap_or(false) {
self.fence.clear_intent(&prepared.intent);
return Ok(result.into());
}
match crate::fence::commit_effect_cert(&self.fence, prepared, stored) {
Ok(_cert) => Ok(result.into()),
Err(e) => {
tracing_warn(&format!("wrap: commit failed: {e}"));
Ok(result.into())
}
}
}
Ok(Admission::Replay(cert)) => {
let replay: CallToolResult =
serde_json::from_value(cert.result.clone()).map_err(|e| {
McpError::internal_error(
format!("stored result no longer deserializes: {e}"),
None,
)
})?;
Ok(replay.into())
}
Err(FenceError::IntentInFlight { .. }) => {
match self
.await_holder(&intent_for_wait, &args_for_wait, &tool_name)
.await
{
Some(replay) => Ok(replay.into()),
None => Ok(CallToolResponse::Complete(CallToolResult::error(vec![
ContentBlock::text(format!(
"effectfence: an identical call to `{tool_name}` is still running and \
did not finish within {}s. It was NOT executed a second time. Retry \
to receive its recorded result.",
IN_FLIGHT_WAIT.as_secs()
)),
]))),
}
}
Err(err @ FenceError::IntentFailed { .. }) => {
Ok(CallToolResponse::Complete(CallToolResult::error(vec![
ContentBlock::text(format!(
"effectfence: call fenced ({err}). An earlier identical call to \
`{tool_name}` failed with its outcome UNKNOWN, so this one was NOT \
executed and retrying it cannot clear the fence. Check the downstream \
system for whether the first attempt took effect; if it did not, this \
action needs a new intent (vary the arguments) or an operator reset."
)),
])))
}
Err(err) => Ok(CallToolResponse::Complete(CallToolResult::error(vec![
ContentBlock::text(format!(
"effectfence: call refused ({err}). This action was NOT executed. If it was \
intentionally a NEW action, vary the arguments; if a dependency moved, re-read \
and try again."
)),
]))),
}
}
}
fn tracing_warn(msg: &str) {
eprintln!("[effectfence-wrap] {msg}");
}
fn fence_config_from_env() -> crate::fence::FenceConfig {
fn secs(key: &str, default: u64) -> std::time::Duration {
let parsed = std::env::var(key)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|n| *n > 0);
if std::env::var(key).is_ok() && parsed.is_none() {
eprintln!(
"[effectfence-wrap] ignoring {key}: not a positive integer; using {default}s"
);
}
std::time::Duration::from_secs(parsed.unwrap_or(default))
}
crate::fence::FenceConfig {
lease_ttl: secs("EFFECTFENCE_LEASE_SECS", 60),
result_ttl: secs("EFFECTFENCE_RESULT_TTL_SECS", 24 * 60 * 60),
}
}
pub async fn run_wrap(child_cmd: Vec<String>) -> Result<()> {
let (program, rest) = child_cmd
.split_first()
.context("wrap: no child command given. Usage: effectfence wrap -- <command> [args...]")?;
let transport = TokioChildProcess::new(tokio::process::Command::new(program).configure(|c| {
c.args(rest);
}))
.context("wrap: failed to spawn child MCP server")?;
let child = ().serve(transport).await.context("wrap: MCP handshake with child failed")?;
let tools = child
.list_all_tools()
.await
.context("wrap: could not list child tools")?;
eprintln!(
"[effectfence-wrap] fencing {} tool(s) from `{}`",
tools.len(),
program
);
let server = WrapServer {
child: Arc::new(child),
fence: EffectFence::with_config(fence_config_from_env()),
tools: Arc::new(tools),
child_name: Arc::from(program.as_str()),
};
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}