Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
//! mcp-gate: MCP (Model Context Protocol) server for the Anytime-Valid Coherence Gate
//!
//! This crate provides an MCP server that enables AI agents to request permissions
//! from the coherence gate. It implements the Model Context Protocol for
//! stdio-based communication with tool orchestrators.
//!
//! # MCP Tools
//!
//! The server exposes three main tools:
//!
//! - **permit_action**: Request permission for an action. Returns a PermitToken
//! for permitted actions, escalation info for deferred actions, or denial details.
//!
//! - **get_receipt**: Retrieve a witness receipt by sequence number for audit purposes.
//! Each decision generates a cryptographically signed receipt.
//!
//! - **replay_decision**: Deterministically replay a past decision for audit and
//! verification. Optionally verifies the hash chain integrity.
//!
//! # Example Usage
//!
//! ```no_run
//! use mcp_gate::McpGateServer;
//!
//! #[tokio::main]
//! async fn main() {
//! let server = McpGateServer::new();
//! server.run_stdio().await.expect("Server failed");
//! }
//! ```
//!
//! # Protocol
//!
//! The server uses JSON-RPC 2.0 over stdio. Example request:
//!
//! ```json
//! {
//! "jsonrpc": "2.0",
//! "id": 1,
//! "method": "tools/call",
//! "params": {
//! "name": "permit_action",
//! "arguments": {
//! "action_id": "cfg-push-7a3f",
//! "action_type": "config_change",
//! "target": {
//! "device": "router-west-03",
//! "path": "/network/interfaces/eth0"
//! }
//! }
//! }
//! }
//! ```
pub mod server;
pub mod tools;
pub mod types;
// Re-export main types
pub use server::{McpGateConfig, McpGateServer, ServerCapabilities, ServerInfo};
pub use tools::{McpError, McpGateTools};
pub use types::*;
// Re-export types from cognitum-gate-tilezero for convenience
pub use cognitum_gate_tilezero::{
ActionContext, ActionMetadata, ActionTarget, EscalationInfo, GateDecision, GateThresholds,
PermitToken, TileZero, WitnessReceipt,
};

View File

@@ -0,0 +1,66 @@
//! MCP Gate server binary
//!
//! Runs the MCP Gate server on stdio for integration with AI agents.
use mcp_gate::{McpGateConfig, McpGateServer};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(fmt::layer().with_writer(std::io::stderr))
.with(filter)
.init();
// Load config from environment or use defaults
let config = load_config();
// Create and run server
let server = McpGateServer::with_thresholds(config.thresholds);
tracing::info!("MCP Gate server v{} starting", env!("CARGO_PKG_VERSION"));
server.run_stdio().await?;
Ok(())
}
fn load_config() -> McpGateConfig {
// Try to load from environment variables
let mut config = McpGateConfig::default();
if let Ok(tau_deny) = std::env::var("MCP_GATE_TAU_DENY") {
if let Ok(v) = tau_deny.parse() {
config.thresholds.tau_deny = v;
}
}
if let Ok(tau_permit) = std::env::var("MCP_GATE_TAU_PERMIT") {
if let Ok(v) = tau_permit.parse() {
config.thresholds.tau_permit = v;
}
}
if let Ok(min_cut) = std::env::var("MCP_GATE_MIN_CUT") {
if let Ok(v) = min_cut.parse() {
config.thresholds.min_cut = v;
}
}
if let Ok(max_shift) = std::env::var("MCP_GATE_MAX_SHIFT") {
if let Ok(v) = max_shift.parse() {
config.thresholds.max_shift = v;
}
}
if let Ok(ttl) = std::env::var("MCP_GATE_PERMIT_TTL_NS") {
if let Ok(v) = ttl.parse() {
config.thresholds.permit_ttl_ns = v;
}
}
config
}

View File

@@ -0,0 +1,357 @@
//! MCP protocol server implementation
//!
//! Implements the Model Context Protocol for stdio-based communication
//! with AI agents and tool orchestrators.
use crate::tools::McpGateTools;
use crate::types::*;
use cognitum_gate_tilezero::{GateThresholds, TileZero};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
/// MCP Gate Server
pub struct McpGateServer {
/// Tools handler
tools: McpGateTools,
/// Server info
server_info: ServerInfo,
}
/// Server information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ServerInfo {
/// Server name
pub name: String,
/// Server version
pub version: String,
/// Protocol version
pub protocol_version: String,
}
impl Default for ServerInfo {
fn default() -> Self {
Self {
name: "mcp-gate".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
protocol_version: "2024-11-05".to_string(),
}
}
}
/// Server capabilities
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ServerCapabilities {
/// Tool capabilities
pub tools: ToolCapabilities,
}
/// Tool capabilities
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolCapabilities {
/// Whether tool listing changes are supported
#[serde(rename = "listChanged")]
pub list_changed: bool,
}
impl Default for ServerCapabilities {
fn default() -> Self {
Self {
tools: ToolCapabilities {
list_changed: false,
},
}
}
}
impl McpGateServer {
/// Create a new server with default configuration
pub fn new() -> Self {
let thresholds = GateThresholds::default();
let tilezero = Arc::new(RwLock::new(TileZero::new(thresholds)));
Self {
tools: McpGateTools::new(tilezero),
server_info: ServerInfo::default(),
}
}
/// Create a new server with custom thresholds
pub fn with_thresholds(thresholds: GateThresholds) -> Self {
let tilezero = Arc::new(RwLock::new(TileZero::new(thresholds)));
Self {
tools: McpGateTools::new(tilezero),
server_info: ServerInfo::default(),
}
}
/// Create a new server with a shared TileZero instance
pub fn with_tilezero(tilezero: Arc<RwLock<TileZero>>) -> Self {
Self {
tools: McpGateTools::new(tilezero),
server_info: ServerInfo::default(),
}
}
/// Run the server on stdio
pub async fn run_stdio(&self) -> Result<(), std::io::Error> {
info!("Starting MCP Gate server on stdio");
let stdin = tokio::io::stdin();
let mut stdout = tokio::io::stdout();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
debug!("Received: {}", line);
let response = self.handle_message(&line).await;
if let Some(resp) = response {
let resp_json = serde_json::to_string(&resp).unwrap_or_default();
debug!("Sending: {}", resp_json);
stdout.write_all(resp_json.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
info!("MCP Gate server shutting down");
Ok(())
}
/// Handle a single message
async fn handle_message(&self, message: &str) -> Option<JsonRpcResponse> {
let request: JsonRpcRequest = match serde_json::from_str(message) {
Ok(req) => req,
Err(e) => {
error!("Failed to parse request: {}", e);
return Some(JsonRpcResponse::error(
serde_json::Value::Null,
-32700,
format!("Parse error: {}", e),
));
}
};
let result = self.handle_request(&request).await;
Some(result)
}
/// Handle a JSON-RPC request
async fn handle_request(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
match request.method.as_str() {
"initialize" => self.handle_initialize(request),
"initialized" => {
// Notification, no response needed
JsonRpcResponse::success(request.id.clone(), serde_json::json!({}))
}
"tools/list" => self.handle_tools_list(request),
"tools/call" => self.handle_tools_call(request).await,
"shutdown" => {
info!("Received shutdown request");
JsonRpcResponse::success(request.id.clone(), serde_json::json!({}))
}
_ => {
warn!("Unknown method: {}", request.method);
JsonRpcResponse::error(
request.id.clone(),
-32601,
format!("Method not found: {}", request.method),
)
}
}
}
/// Handle initialize request
fn handle_initialize(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
info!("Handling initialize request");
let result = serde_json::json!({
"protocolVersion": self.server_info.protocol_version,
"capabilities": ServerCapabilities::default(),
"serverInfo": {
"name": self.server_info.name,
"version": self.server_info.version
}
});
JsonRpcResponse::success(request.id.clone(), result)
}
/// Handle tools/list request
fn handle_tools_list(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
info!("Handling tools/list request");
let tools = McpGateTools::list_tools();
let result = serde_json::json!({
"tools": tools
});
JsonRpcResponse::success(request.id.clone(), result)
}
/// Handle tools/call request
async fn handle_tools_call(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
info!("Handling tools/call request");
// Parse the tool call from params
let tool_call: McpToolCall = match serde_json::from_value(request.params.clone()) {
Ok(tc) => tc,
Err(e) => {
return JsonRpcResponse::error(
request.id.clone(),
-32602,
format!("Invalid params: {}", e),
);
}
};
// Call the tool
match self.tools.call_tool(tool_call).await {
Ok(result) => {
let response_content = match result {
McpToolResult::Success { content } => serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&content).unwrap_or_default()
}]
}),
McpToolResult::Error { error } => serde_json::json!({
"content": [{
"type": "text",
"text": error
}],
"isError": true
}),
};
JsonRpcResponse::success(request.id.clone(), response_content)
}
Err(e) => JsonRpcResponse::error(request.id.clone(), e.code(), e.to_string()),
}
}
}
impl Default for McpGateServer {
fn default() -> Self {
Self::new()
}
}
/// Configuration for the MCP Gate server
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct McpGateConfig {
/// Gate thresholds
#[serde(default)]
pub thresholds: GateThresholds,
/// Log level
#[serde(default = "default_log_level")]
pub log_level: String,
}
fn default_log_level() -> String {
"info".to_string()
}
impl Default for McpGateConfig {
fn default() -> Self {
Self {
thresholds: GateThresholds::default(),
log_level: default_log_level(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_info_default() {
let info = ServerInfo::default();
assert_eq!(info.name, "mcp-gate");
assert_eq!(info.protocol_version, "2024-11-05");
}
#[test]
fn test_server_capabilities_default() {
let caps = ServerCapabilities::default();
assert!(!caps.tools.list_changed);
}
#[tokio::test]
async fn test_handle_initialize() {
let server = McpGateServer::new();
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(1),
method: "initialize".to_string(),
params: serde_json::json!({}),
};
let response = server.handle_request(&request).await;
assert!(response.result.is_some());
assert!(response.error.is_none());
let result = response.result.unwrap();
assert_eq!(result["protocolVersion"], "2024-11-05");
}
#[tokio::test]
async fn test_handle_tools_list() {
let server = McpGateServer::new();
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(1),
method: "tools/list".to_string(),
params: serde_json::json!({}),
};
let response = server.handle_request(&request).await;
assert!(response.result.is_some());
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 3);
}
#[tokio::test]
async fn test_handle_tools_call() {
let server = McpGateServer::new();
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(1),
method: "tools/call".to_string(),
params: serde_json::json!({
"name": "permit_action",
"arguments": {
"action_id": "test-1",
"action_type": "config_change"
}
}),
};
let response = server.handle_request(&request).await;
assert!(response.result.is_some());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_handle_unknown_method() {
let server = McpGateServer::new();
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(1),
method: "unknown/method".to_string(),
params: serde_json::json!({}),
};
let response = server.handle_request(&request).await;
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32601);
}
}

View File

@@ -0,0 +1,457 @@
//! MCP tools for the coherence gate
//!
//! Provides three main tools:
//! - permit_action: Request permission for an action
//! - get_receipt: Get a witness receipt by sequence number
//! - replay_decision: Deterministically replay a decision for audit
use crate::types::*;
use cognitum_gate_tilezero::{GateDecision, TileZero, WitnessReceipt};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Error type for MCP tool operations
#[derive(Debug, thiserror::Error)]
pub enum McpError {
#[error("Receipt not found: sequence {0}")]
ReceiptNotFound(u64),
#[error("Chain verification failed: {0}")]
ChainVerifyFailed(String),
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Internal error: {0}")]
Internal(String),
}
impl McpError {
/// Convert to JSON-RPC error code
pub fn code(&self) -> i32 {
match self {
McpError::ReceiptNotFound(_) => -32001,
McpError::ChainVerifyFailed(_) => -32002,
McpError::InvalidRequest(_) => -32602,
McpError::Internal(_) => -32603,
}
}
}
/// MCP Gate tools handler
pub struct McpGateTools {
/// TileZero instance
tilezero: Arc<RwLock<TileZero>>,
}
impl McpGateTools {
/// Create a new tools handler
pub fn new(tilezero: Arc<RwLock<TileZero>>) -> Self {
Self { tilezero }
}
/// Get the list of available tools
pub fn list_tools() -> Vec<McpTool> {
vec![
McpTool {
name: "permit_action".to_string(),
description: "Request permission for an action from the coherence gate. Returns a PermitToken for permitted actions, escalation info for deferred actions, or denial details.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"action_id": {
"type": "string",
"description": "Unique identifier for this action"
},
"action_type": {
"type": "string",
"description": "Type of action (e.g., config_change, api_call)"
},
"target": {
"type": "object",
"properties": {
"device": { "type": "string" },
"path": { "type": "string" }
}
},
"context": {
"type": "object",
"properties": {
"agent_id": { "type": "string" },
"session_id": { "type": "string" },
"prior_actions": {
"type": "array",
"items": { "type": "string" }
},
"urgency": { "type": "string" }
}
}
},
"required": ["action_id", "action_type"]
}),
},
McpTool {
name: "get_receipt".to_string(),
description: "Retrieve a witness receipt by sequence number for audit purposes.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"sequence": {
"type": "integer",
"description": "Sequence number of the receipt to retrieve"
}
},
"required": ["sequence"]
}),
},
McpTool {
name: "replay_decision".to_string(),
description: "Deterministically replay a past decision for audit and verification.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"sequence": {
"type": "integer",
"description": "Sequence number of the decision to replay"
},
"verify_chain": {
"type": "boolean",
"description": "Whether to verify the hash chain up to this decision"
}
},
"required": ["sequence"]
}),
},
]
}
/// Handle a tool call
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResult, McpError> {
match call.name.as_str() {
"permit_action" => {
let request: PermitActionRequest = serde_json::from_value(call.arguments)
.map_err(|e| McpError::InvalidRequest(e.to_string()))?;
let response = self.permit_action(request).await?;
Ok(McpToolResult::Success {
content: serde_json::to_value(response)
.map_err(|e| McpError::Internal(e.to_string()))?,
})
}
"get_receipt" => {
let request: GetReceiptRequest = serde_json::from_value(call.arguments)
.map_err(|e| McpError::InvalidRequest(e.to_string()))?;
let response = self.get_receipt(request).await?;
Ok(McpToolResult::Success {
content: serde_json::to_value(response)
.map_err(|e| McpError::Internal(e.to_string()))?,
})
}
"replay_decision" => {
let request: ReplayDecisionRequest = serde_json::from_value(call.arguments)
.map_err(|e| McpError::InvalidRequest(e.to_string()))?;
let response = self.replay_decision(request).await?;
Ok(McpToolResult::Success {
content: serde_json::to_value(response)
.map_err(|e| McpError::Internal(e.to_string()))?,
})
}
_ => Err(McpError::InvalidRequest(format!(
"Unknown tool: {}",
call.name
))),
}
}
/// Request permission for an action
pub async fn permit_action(
&self,
request: PermitActionRequest,
) -> Result<PermitActionResponse, McpError> {
let ctx = request.to_action_context();
let tilezero = self.tilezero.read().await;
let token = tilezero.decide(&ctx).await;
// Get the receipt for witness info
let receipt = tilezero
.get_receipt(token.sequence)
.await
.ok_or_else(|| McpError::Internal("Failed to get receipt".to_string()))?;
let witness = self.build_witness_info(&receipt);
match token.decision {
GateDecision::Permit => Ok(PermitActionResponse::Permit(PermitResponse {
token: token.encode_base64(),
valid_until_ns: token.timestamp + token.ttl_ns,
witness,
receipt_sequence: token.sequence,
})),
GateDecision::Defer => {
let reason = self.determine_defer_reason(&receipt);
Ok(PermitActionResponse::Defer(DeferResponse {
reason: reason.0,
detail: reason.1,
escalation: EscalationInfo {
to: "human_operator".to_string(),
context_url: format!("/receipts/{}/context", token.sequence),
timeout_ns: 300_000_000_000, // 5 minutes
default_on_timeout: "deny".to_string(),
},
witness,
receipt_sequence: token.sequence,
}))
}
GateDecision::Deny => {
let reason = self.determine_deny_reason(&receipt);
Ok(PermitActionResponse::Deny(DenyResponse {
reason: reason.0,
detail: reason.1,
witness,
receipt_sequence: token.sequence,
}))
}
}
}
/// Get a witness receipt
pub async fn get_receipt(
&self,
request: GetReceiptRequest,
) -> Result<GetReceiptResponse, McpError> {
let tilezero = self.tilezero.read().await;
let receipt = tilezero
.get_receipt(request.sequence)
.await
.ok_or(McpError::ReceiptNotFound(request.sequence))?;
Ok(GetReceiptResponse {
sequence: receipt.sequence,
decision: receipt.token.decision.to_string(),
timestamp: receipt.token.timestamp,
witness_summary: receipt.witness_summary.to_json(),
previous_hash: hex::encode(receipt.previous_hash),
receipt_hash: hex::encode(receipt.hash()),
})
}
/// Replay a decision for audit
pub async fn replay_decision(
&self,
request: ReplayDecisionRequest,
) -> Result<ReplayDecisionResponse, McpError> {
let tilezero = self.tilezero.read().await;
// Optionally verify hash chain
if request.verify_chain {
tilezero
.verify_chain_to(request.sequence)
.await
.map_err(|e| McpError::ChainVerifyFailed(e.to_string()))?;
}
// Get the original receipt
let receipt = tilezero
.get_receipt(request.sequence)
.await
.ok_or(McpError::ReceiptNotFound(request.sequence))?;
// Replay the decision
let replayed = tilezero.replay(&receipt).await;
Ok(ReplayDecisionResponse {
original_decision: receipt.token.decision.to_string(),
replayed_decision: replayed.decision.to_string(),
match_confirmed: receipt.token.decision == replayed.decision,
state_snapshot: replayed.state_snapshot.to_json(),
})
}
/// Build witness info from a receipt
fn build_witness_info(&self, receipt: &WitnessReceipt) -> WitnessInfo {
let summary = &receipt.witness_summary;
WitnessInfo {
structural: StructuralInfo {
cut_value: summary.structural.cut_value,
partition: summary.structural.partition.clone(),
critical_edges: Some(summary.structural.critical_edges),
boundary: if summary.structural.boundary.is_empty() {
None
} else {
Some(summary.structural.boundary.clone())
},
},
predictive: PredictiveInfo {
set_size: summary.predictive.set_size,
coverage: summary.predictive.coverage,
},
evidential: EvidentialInfo {
e_value: summary.evidential.e_value,
verdict: summary.evidential.verdict.clone(),
},
}
}
/// Determine the reason for a DEFER decision
fn determine_defer_reason(&self, receipt: &WitnessReceipt) -> (String, String) {
let summary = &receipt.witness_summary;
// Check predictive uncertainty
if summary.predictive.set_size > 10 {
return (
"prediction_uncertainty".to_string(),
format!(
"Prediction set size {} indicates high uncertainty",
summary.predictive.set_size
),
);
}
// Check evidential indeterminate
if summary.evidential.verdict == "continue" {
return (
"insufficient_evidence".to_string(),
format!(
"E-value {} is in indeterminate range",
summary.evidential.e_value
),
);
}
// Default
(
"shift_detected".to_string(),
"Distribution shift detected, escalating for human review".to_string(),
)
}
/// Determine the reason for a DENY decision
fn determine_deny_reason(&self, receipt: &WitnessReceipt) -> (String, String) {
let summary = &receipt.witness_summary;
// Check structural violation
if summary.structural.partition == "fragile" {
return (
"boundary_violation".to_string(),
format!(
"Action crosses fragile partition (cut={:.1} is below minimum)",
summary.structural.cut_value
),
);
}
// Check evidential rejection
if summary.evidential.verdict == "reject" {
return (
"evidence_rejection".to_string(),
format!(
"E-value {:.4} indicates strong evidence of incoherence",
summary.evidential.e_value
),
);
}
// Default
(
"policy_violation".to_string(),
"Action violates gate policy".to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cognitum_gate_tilezero::GateThresholds;
#[tokio::test]
async fn test_permit_action() {
let tilezero = Arc::new(RwLock::new(TileZero::new(GateThresholds::default())));
let tools = McpGateTools::new(tilezero);
let request = PermitActionRequest {
action_id: "test-action-1".to_string(),
action_type: "config_change".to_string(),
target: TargetInfo {
device: Some("router-1".to_string()),
path: Some("/config".to_string()),
extra: Default::default(),
},
context: ContextInfo {
agent_id: "agent-1".to_string(),
session_id: Some("session-1".to_string()),
prior_actions: vec![],
urgency: "normal".to_string(),
},
};
let response = tools.permit_action(request).await.unwrap();
match response {
PermitActionResponse::Permit(p) => {
assert!(!p.token.is_empty());
assert!(p.receipt_sequence == 0);
}
PermitActionResponse::Defer(d) => {
assert!(!d.reason.is_empty());
}
PermitActionResponse::Deny(d) => {
assert!(!d.reason.is_empty());
}
}
}
#[tokio::test]
async fn test_get_receipt() {
let tilezero = Arc::new(RwLock::new(TileZero::new(GateThresholds::default())));
let tools = McpGateTools::new(tilezero);
// First create a decision
let request = PermitActionRequest {
action_id: "test-action-1".to_string(),
action_type: "config_change".to_string(),
target: Default::default(),
context: Default::default(),
};
let _ = tools.permit_action(request).await.unwrap();
// Now get the receipt
let receipt_response = tools
.get_receipt(GetReceiptRequest { sequence: 0 })
.await
.unwrap();
assert_eq!(receipt_response.sequence, 0);
assert!(!receipt_response.receipt_hash.is_empty());
}
#[tokio::test]
async fn test_replay_decision() {
let tilezero = Arc::new(RwLock::new(TileZero::new(GateThresholds::default())));
let tools = McpGateTools::new(tilezero);
// First create a decision
let request = PermitActionRequest {
action_id: "test-action-1".to_string(),
action_type: "config_change".to_string(),
target: Default::default(),
context: Default::default(),
};
let _ = tools.permit_action(request).await.unwrap();
// Replay the decision
let replay_response = tools
.replay_decision(ReplayDecisionRequest {
sequence: 0,
verify_chain: true,
})
.await
.unwrap();
assert!(replay_response.match_confirmed);
}
#[test]
fn test_list_tools() {
let tools = McpGateTools::list_tools();
assert_eq!(tools.len(), 3);
assert_eq!(tools[0].name, "permit_action");
assert_eq!(tools[1].name, "get_receipt");
assert_eq!(tools[2].name, "replay_decision");
}
}

View File

@@ -0,0 +1,389 @@
//! Request/response types for the MCP Gate server
//!
//! These types match the API contract defined in ADR-001.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Re-export types from cognitum-gate-tilezero
pub use cognitum_gate_tilezero::{
ActionContext, ActionMetadata, ActionTarget, EscalationInfo, GateDecision, GateThresholds,
PermitToken, WitnessReceipt,
};
/// Request to permit an action
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermitActionRequest {
/// Unique identifier for this action
pub action_id: String,
/// Type of action (e.g., "config_change", "api_call")
pub action_type: String,
/// Target of the action
#[serde(default)]
pub target: TargetInfo,
/// Additional context
#[serde(default)]
pub context: ContextInfo,
}
/// Target information for an action
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TargetInfo {
/// Target device/resource
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
/// Target path
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Additional target properties
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
/// Context information for an action
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ContextInfo {
/// Agent requesting the action
#[serde(default)]
pub agent_id: String,
/// Session identifier
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Prior related actions
#[serde(default)]
pub prior_actions: Vec<String>,
/// Urgency level
#[serde(default = "default_urgency")]
pub urgency: String,
}
fn default_urgency() -> String {
"normal".to_string()
}
impl PermitActionRequest {
/// Convert to ActionContext for the gate
pub fn to_action_context(&self) -> ActionContext {
ActionContext {
action_id: self.action_id.clone(),
action_type: self.action_type.clone(),
target: ActionTarget {
device: self.target.device.clone(),
path: self.target.path.clone(),
extra: self.target.extra.clone(),
},
context: ActionMetadata {
agent_id: self.context.agent_id.clone(),
session_id: self.context.session_id.clone(),
prior_actions: self.context.prior_actions.clone(),
urgency: self.context.urgency.clone(),
},
}
}
}
/// Response to a permit action request
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "lowercase")]
pub enum PermitActionResponse {
/// Action is permitted
Permit(PermitResponse),
/// Action is deferred for escalation
Defer(DeferResponse),
/// Action is denied
Deny(DenyResponse),
}
/// Permit response details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermitResponse {
/// Base64-encoded permit token
pub token: String,
/// Token valid until (nanoseconds since epoch)
pub valid_until_ns: u64,
/// Witness summary
pub witness: WitnessInfo,
/// Receipt sequence number
pub receipt_sequence: u64,
}
/// Defer response details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeferResponse {
/// Reason for deferral
pub reason: String,
/// Detailed explanation
pub detail: String,
/// Escalation information
pub escalation: EscalationInfo,
/// Witness summary
pub witness: WitnessInfo,
/// Receipt sequence number
pub receipt_sequence: u64,
}
/// Deny response details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DenyResponse {
/// Reason for denial
pub reason: String,
/// Detailed explanation
pub detail: String,
/// Witness summary
pub witness: WitnessInfo,
/// Receipt sequence number
pub receipt_sequence: u64,
}
/// Witness information in responses
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WitnessInfo {
/// Structural witness
pub structural: StructuralInfo,
/// Predictive witness
pub predictive: PredictiveInfo,
/// Evidential witness
pub evidential: EvidentialInfo,
}
/// Structural witness details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuralInfo {
/// Cut value
pub cut_value: f64,
/// Partition status
pub partition: String,
/// Number of critical edges
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_edges: Option<usize>,
/// Boundary edge IDs
#[serde(skip_serializing_if = "Option::is_none")]
pub boundary: Option<Vec<String>>,
}
/// Predictive witness details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictiveInfo {
/// Prediction set size
pub set_size: usize,
/// Coverage target
pub coverage: f64,
}
/// Evidential witness details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidentialInfo {
/// Accumulated e-value
pub e_value: f64,
/// Verdict (accept/continue/reject)
pub verdict: String,
}
/// Request to get a receipt
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetReceiptRequest {
/// Sequence number of the receipt
pub sequence: u64,
}
/// Response with receipt details
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetReceiptResponse {
/// Sequence number
pub sequence: u64,
/// Decision that was made
pub decision: String,
/// Timestamp (nanoseconds since epoch)
pub timestamp: u64,
/// Witness summary as JSON
pub witness_summary: serde_json::Value,
/// Hash of previous receipt
pub previous_hash: String,
/// Hash of this receipt
pub receipt_hash: String,
}
/// Request to replay a decision
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayDecisionRequest {
/// Sequence number of the decision to replay
pub sequence: u64,
/// Whether to verify the hash chain
#[serde(default)]
pub verify_chain: bool,
}
/// Response from replaying a decision
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayDecisionResponse {
/// Original decision
pub original_decision: String,
/// Replayed decision
pub replayed_decision: String,
/// Whether the decisions match
pub match_confirmed: bool,
/// State snapshot as JSON
pub state_snapshot: serde_json::Value,
}
/// MCP Tool definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
/// Tool name
pub name: String,
/// Tool description
pub description: String,
/// Input schema
pub input_schema: serde_json::Value,
}
/// MCP Tool call request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCall {
/// Tool name
pub name: String,
/// Tool arguments
pub arguments: serde_json::Value,
}
/// MCP Tool result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum McpToolResult {
/// Successful result
Success { content: serde_json::Value },
/// Error result
Error { error: String },
}
/// MCP JSON-RPC request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
/// JSON-RPC version
pub jsonrpc: String,
/// Request ID
pub id: serde_json::Value,
/// Method name
pub method: String,
/// Parameters
#[serde(default)]
pub params: serde_json::Value,
}
/// MCP JSON-RPC response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
/// JSON-RPC version
pub jsonrpc: String,
/// Request ID
pub id: serde_json::Value,
/// Result (if success)
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
/// Error (if failure)
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
/// JSON-RPC error
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
/// Error code
pub code: i32,
/// Error message
pub message: String,
/// Additional data
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
impl JsonRpcResponse {
/// Create a success response
pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: Some(result),
error: None,
}
}
/// Create an error response
pub fn error(id: serde_json::Value, code: i32, message: String) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code,
message,
data: None,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permit_request_deserialize() {
let json = r#"{
"action_id": "cfg-push-7a3f",
"action_type": "config_change",
"target": {
"device": "router-west-03",
"path": "/network/interfaces/eth0"
},
"context": {
"agent_id": "ops-agent-12",
"session_id": "sess-abc123",
"prior_actions": ["cfg-push-7a3e"],
"urgency": "normal"
}
}"#;
let req: PermitActionRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.action_id, "cfg-push-7a3f");
assert_eq!(req.target.device, Some("router-west-03".to_string()));
}
#[test]
fn test_permit_response_serialize() {
let resp = PermitActionResponse::Permit(PermitResponse {
token: "eyJ0eXAi...".to_string(),
valid_until_ns: 1737158400000000000,
witness: WitnessInfo {
structural: StructuralInfo {
cut_value: 12.7,
partition: "stable".to_string(),
critical_edges: Some(0),
boundary: None,
},
predictive: PredictiveInfo {
set_size: 3,
coverage: 0.92,
},
evidential: EvidentialInfo {
e_value: 847.3,
verdict: "accept".to_string(),
},
},
receipt_sequence: 1847392,
});
let json = serde_json::to_string_pretty(&resp).unwrap();
assert!(json.contains("permit"));
assert!(json.contains("1847392"));
}
#[test]
fn test_jsonrpc_response() {
let resp =
JsonRpcResponse::success(serde_json::json!(1), serde_json::json!({"status": "ok"}));
assert_eq!(resp.jsonrpc, "2.0");
assert!(resp.result.is_some());
assert!(resp.error.is_none());
}
}