effectfence 0.2.0

Causal concurrency fence for multi-agent tool calls: an intent ledger, OCC read-sets, and atomic CAS domain reservation stop double-execution — same-instant races and late duplicate retries alike. Ships as a library and an MCP server.
Documentation
//! MCP (Model Context Protocol) stdio server exposing the causal effect
//! fence (see [`effectfence::fence`]) as three tools -- `fence_prepare`,
//! `fence_commit`, and `fence_abort` -- so agents can route
//! side-effecting tool calls through the fence instead of racing each
//! other (or re-running each other's completed work) directly.
//!
//! Built on the official `rmcp` SDK rather than a hand-rolled JSON-RPC
//! layer.

use std::io::IsTerminal;

use anyhow::Result;
use effectfence::fence::{
    Admission, EffectFence, EffectRequest, PreparedEffect, abort_effect, commit_effect_cert,
    prepare_effect_fence,
};
use rmcp::{
    ErrorData as McpError, ServerHandler, ServiceExt,
    handler::server::wrapper::Parameters,
    model::{CallToolResult, ContentBlock},
    schemars, tool, tool_handler, tool_router,
    transport::stdio,
};
use serde::Deserialize;
use serde_json::Value;

/// Parameters for the `fence_commit` tool.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct FenceCommitArgs {
    /// The prepared-effect ticket returned by a prior `fence_prepare`
    /// call, passed back verbatim.
    prepared: PreparedEffect,
    /// The actual result of running the tool/effect.
    // Any JSON type -- a bare `Value` emits no `type`, which MCP clients
    // cannot render. See effectfence::fence::any_json_schema.
    #[serde(default)]
    #[schemars(schema_with = "effectfence::fence::any_json_schema")]
    result: Value,
}

/// Parameters for the `fence_abort` tool.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct FenceHeartbeatArgs {
    /// The intent whose lease should be extended — the same id passed to
    /// `fence_prepare`.
    intent: String,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct FenceAbortArgs {
    /// The prepared-effect ticket returned by a prior `fence_prepare`
    /// call, passed back verbatim.
    prepared: PreparedEffect,
    /// Why the effect failed (or why its outcome is unknown).
    reason: String,
}

/// The MCP server. Holds the one [`EffectFence`] shared by every tool
/// call for the life of the process.
#[derive(Clone, Default)]
struct EffectFenceServer {
    fence: EffectFence,
}

fn json_result(value: Value) -> Result<CallToolResult, McpError> {
    let json =
        serde_json::to_string(&value).map_err(|e| McpError::internal_error(e.to_string(), None))?;
    Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}

#[tool_router]
impl EffectFenceServer {
    #[tool(
        description = "Ask permission to run a side-effecting tool call. `intent` is a stable id for the logical action (e.g. `charge:order-123`) -- attempts sharing an intent are the SAME action and only one will ever execute. Returns {status:'fresh', prepared} when this attempt wins (run the tool, then call fence_commit or fence_abort with the ticket), or {status:'already_done', cert} when this exact action already ran (use the recorded result; do NOT run the tool). Errors mean do not run: another attempt is in flight, a previous attempt failed (reconcile first), a dependency changed (ReadSetStale), or a same-instant race was lost (DomainRace)."
    )]
    async fn fence_prepare(
        &self,
        Parameters(req): Parameters<EffectRequest>,
    ) -> Result<CallToolResult, McpError> {
        match prepare_effect_fence(&self.fence, req) {
            Ok(Admission::Fresh(prepared)) => {
                json_result(serde_json::json!({"status": "fresh", "prepared": prepared}))
            }
            Ok(Admission::Replay(cert)) => {
                json_result(serde_json::json!({"status": "already_done", "cert": &*cert}))
            }
            Err(err) => Ok(CallToolResult::error(vec![ContentBlock::text(
                err.to_string(),
            )])),
        }
    }

    #[tool(
        description = "Report that a prepared effect finished, and mint its content-addressed EffectCert. Re-validates the read set one more time; if a dependency moved while the effect was running, the commit is rejected AND the intent is fenced as failed (the effect did run -- reconcile before retrying). On success, later fence_prepare calls with the same intent replay this cert instead of re-running the action."
    )]
    async fn fence_commit(
        &self,
        Parameters(args): Parameters<FenceCommitArgs>,
    ) -> Result<CallToolResult, McpError> {
        match commit_effect_cert(&self.fence, args.prepared, args.result) {
            Ok(cert) => json_result(serde_json::json!({"status": "committed", "cert": cert})),
            Err(err) => Ok(CallToolResult::error(vec![ContentBlock::text(
                err.to_string(),
            )])),
        }
    }

    #[tool(
        description = "Report that a prepared effect failed or its outcome is unknown. The intent stays fenced -- later fence_prepare calls with the same intent are rejected instead of silently re-running an action whose side effect may have fired -- until an operator reconciles with the downstream system and clears it."
    )]
    async fn fence_abort(
        &self,
        Parameters(args): Parameters<FenceAbortArgs>,
    ) -> Result<CallToolResult, McpError> {
        let intent = args.prepared.intent.clone();
        abort_effect(&self.fence, args.prepared, args.reason);
        json_result(serde_json::json!({"status": "aborted", "intent": intent}))
    }

    #[tool(
        description = "Say that a prepared effect is still running, pushing its lease out. Call this periodically (about every third of the lease) between fence_prepare and fence_commit whenever an effect may run longer than the lease -- otherwise the lease lapses, another attempt is admitted, and the SAME action runs twice. Answers held=false if the intent is not in flight (unknown, already committed, or already failed): that is never a reason to run the effect again."
    )]
    async fn fence_heartbeat(
        &self,
        Parameters(args): Parameters<FenceHeartbeatArgs>,
    ) -> Result<CallToolResult, McpError> {
        let held = self.fence.heartbeat(&args.intent);
        json_result(serde_json::json!({
            "intent": args.intent,
            "held": held,
            "lease_secs": self.fence.lease_ttl().as_secs(),
        }))
    }

    #[tool(
        description = "Live counters since this fence process started: how many effects were admitted (ran), replayed (duplicate handed a recorded result), and refused (stale read-set, lost domain race, already in flight, or fenced after an unknown-outcome failure). `prevented` is every attempt that did NOT run the effect. Takes no arguments."
    )]
    async fn fence_stats(&self) -> Result<CallToolResult, McpError> {
        let s = self.fence.stats();
        json_result(serde_json::json!({
            "admitted": s.admitted,
            "replayed": s.replayed,
            "refused": {
                "stale_read_set": s.refused_stale,
                "domain_race": s.refused_race,
                "in_flight": s.refused_inflight,
                "prior_failure": s.refused_failed,
            },
            "total_attempts": s.total(),
            "prevented": s.prevented(),
            "log_line": s.log_line(),
        }))
    }
}

#[tool_handler(
    name = "effectfence",
    version = "0.2.0", // KEEP IN SYNC with Cargo.toml -- the macro rejects env!(); test below enforces it
    instructions = "Causal effect fencing for multi-agent tool calls. Before any side-effecting tool call (charging, sending, provisioning), call fence_prepare with a stable `intent` id for the action; run the tool only on {status:'fresh'}, then report the outcome with fence_commit (success) or fence_abort (failure). Duplicates of a completed action get its recorded cert back instead of running again, and concurrent attempts at the same action are serialized to exactly one winner."
)]
impl ServerHandler for EffectFenceServer {}

fn print_help() {
    let v = env!("CARGO_PKG_VERSION");
    println!(
        "\
effectfence {v} — stop AI agents double-firing side effects.

Twelve agents reaching for one action (a retry after a timeout, two agents on
one task) can fire it twelve times. effectfence admits it exactly once.

USAGE
  effectfence demo
      See it with zero setup. Nothing installs, no real call fires: twelve
      identical charges hit a built-in server raw (12 duplicates), then behind
      the fence (exactly 1). Start here.

  effectfence probe --tool <name> [--args '<json>'] [--calls N] -- <cmd>...
      Point it at YOUR MCP server. Fires N identical concurrent calls at one
      tool and reports how many real effects landed. Use a TEST tool: it
      really calls the server. Example:
        effectfence probe --tool charge_card --args '{{\"amount\":4900}}' \\
          --calls 12 -- npx -y your-mcp-server

  effectfence wrap -- <cmd that starts an MCP server>...
      Put the fence IN FRONT of a server. Every identical duplicate call
      executes once; later duplicates get the recorded result. No agent
      changes. Example:
        effectfence wrap -- npx -y kubernetes-mcp-server

  effectfence                (no args, piped stdio)
      Run as an MCP server exposing fence_prepare / fence_commit / fence_abort.
      This is what an MCP client launches; it is not meant for a terminal.

  effectfence --help | --version

Docs: https://github.com/aurumflux20/effectfence"
    );
}

#[tokio::main]
async fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();
    match args.get(1).map(String::as_str) {
        Some("--help" | "-h" | "help") => {
            print_help();
            return Ok(());
        }
        Some("--version" | "-V" | "version") => {
            println!("effectfence {}", env!("CARGO_PKG_VERSION"));
            return Ok(());
        }
        Some("demo") => return effectfence::demo::run_demo().await,
        // Hidden: the built-in fake server the demo talks to. Blocking stdin loop.
        Some("__demo-server") => {
            return tokio::task::spawn_blocking(effectfence::demo::run_demo_server).await?;
        }
        Some("wrap") => {
            // `effectfence wrap -- <command> [args...]` (the `--` is optional)
            let child: Vec<String> = args[2..]
                .iter()
                .skip_while(|a| a.as_str() == "--")
                .cloned()
                .collect();
            return effectfence::wrap::run_wrap(child).await;
        }
        Some("probe") => {
            // `effectfence probe --tool <name> [--args '<json>'] [--calls N] -- <command> [args...]`
            return effectfence::probe::run_probe(args[2..].to_vec()).await;
        }
        Some(other) => {
            eprintln!("effectfence: unknown command `{other}`.\n");
            print_help();
            return Ok(());
        }
        None => {
            // No subcommand. A human at a terminal gets help, not a silent hang;
            // an MCP client (which pipes stdio) gets the fenced server.
            if std::io::stdin().is_terminal() {
                print_help();
                return Ok(());
            }
        }
    }
    let service = EffectFenceServer::default().serve(stdio()).await?;
    service.waiting().await?;
    Ok(())
}

#[cfg(test)]
mod version_sync {
    /// The tool_handler macro only accepts a string literal for `version`,
    /// so it cannot use env!("CARGO_PKG_VERSION") directly. This test is
    /// the tripwire: bump Cargo.toml without bumping the literal above and
    /// the suite fails.
    #[test]
    fn server_version_literal_matches_cargo_toml() {
        let src = include_str!("main.rs");
        let needle = format!("version = \"{}\"", env!("CARGO_PKG_VERSION"));
        assert!(
            src.contains(&needle),
            "MCP serverInfo version literal is out of sync with Cargo.toml ({})",
            env!("CARGO_PKG_VERSION")
        );
    }
}

#[cfg(test)]
mod server_json_sync {
    /// `server.json` carries its own copy of the version for the MCP
    /// Registry, and nothing else checks it. It drifted from the package
    /// version once already, which publishes a listing pointing at a
    /// release that isn't the current one.
    #[test]
    fn server_json_version_matches_cargo_toml() {
        let raw = include_str!("../server.json");
        let doc: serde_json::Value = serde_json::from_str(raw).expect("server.json is valid JSON");
        let pkg = env!("CARGO_PKG_VERSION");
        assert_eq!(doc["version"], pkg, "server.json .version is out of sync");
        assert_eq!(
            doc["packages"][0]["version"], pkg,
            "server.json .packages[0].version is out of sync"
        );
    }
}