#[cfg(feature = "mcp")]
mod server {
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::CallToolResult,
tool, tool_handler, tool_router, ServerHandler,
};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Clone)]
pub struct ServerState {
tool_router: ToolRouter<Self>,
}
impl Default for ServerState {
fn default() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for ServerState {}
#[derive(Deserialize, JsonSchema)]
pub struct EmitParams {
pub event_type: String,
pub object: String,
#[serde(default)]
pub payload: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
pub struct VerifyParams {
pub receipt_path: String,
}
#[derive(Deserialize, JsonSchema)]
pub struct ShowParams {
pub receipt_path: String,
}
#[tool_router]
impl ServerState {
#[tool(description = "Emit an operation-event and append it to the working receipt chain.")]
async fn emit_event(
&self, params: Parameters<EmitParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let p = ¶ms.0;
let mut cmd = std::process::Command::new("affi");
cmd.args(["emit", "--type", &p.event_type, "--object", &p.object]);
if let Some(payload) = &p.payload {
cmd.args(["--payload", payload.as_str()]);
}
match cmd.output() {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if out.status.success() {
Ok(CallToolResult::success(vec![rmcp::model::Content::text(
text,
)]))
} else {
let err = String::from_utf8_lossy(&out.stderr).into_owned();
Ok(CallToolResult::error(vec![rmcp::model::Content::text(
format!("emit failed: {err}"),
)]))
}
}
Err(e) => Ok(CallToolResult::error(vec![rmcp::model::Content::text(
format!("could not launch affi: {e}"),
)])),
}
}
#[tool(description = "Verify a receipt and return ACCEPT or REJECT verdict.")]
async fn verify_receipt(
&self, params: Parameters<VerifyParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let path = ¶ms.0.receipt_path;
match std::process::Command::new("affi")
.args(["verify", path])
.output()
{
Ok(out) => {
let verdict = if out.status.success() {
"ACCEPT"
} else {
"REJECT"
};
let detail = String::from_utf8_lossy(&out.stdout).into_owned();
Ok(CallToolResult::success(vec![rmcp::model::Content::text(
format!("{verdict}\n{detail}"),
)]))
}
Err(e) => Ok(CallToolResult::error(vec![rmcp::model::Content::text(
format!("could not launch affi: {e}"),
)])),
}
}
#[tool(description = "Show a human-readable dump of a receipt chain.")]
async fn show_receipt(
&self, params: Parameters<ShowParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let path = ¶ms.0.receipt_path;
match std::process::Command::new("affi")
.args(["show", path])
.output()
{
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if out.status.success() {
Ok(CallToolResult::success(vec![rmcp::model::Content::text(
text,
)]))
} else {
let err = String::from_utf8_lossy(&out.stderr).into_owned();
Ok(CallToolResult::error(vec![rmcp::model::Content::text(
format!("show failed: {err}"),
)]))
}
}
Err(e) => Ok(CallToolResult::error(vec![rmcp::model::Content::text(
format!("could not launch affi: {e}"),
)])),
}
}
}
}
#[cfg(feature = "mcp")]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
use rmcp::{transport::stdio, ServiceExt as _};
server::ServerState::default().serve(stdio()).await?;
Ok(())
}
#[cfg(not(feature = "mcp"))]
fn main() -> anyhow::Result<()> {
eprintln!("mcp_server requires the `mcp` Cargo feature. Rebuild with --features mcp");
std::process::exit(1);
}