Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
68
vendor/ruvector/crates/mcp-gate/src/lib.rs
vendored
Normal file
68
vendor/ruvector/crates/mcp-gate/src/lib.rs
vendored
Normal 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,
|
||||
};
|
||||
66
vendor/ruvector/crates/mcp-gate/src/main.rs
vendored
Normal file
66
vendor/ruvector/crates/mcp-gate/src/main.rs
vendored
Normal 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
|
||||
}
|
||||
357
vendor/ruvector/crates/mcp-gate/src/server.rs
vendored
Normal file
357
vendor/ruvector/crates/mcp-gate/src/server.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
457
vendor/ruvector/crates/mcp-gate/src/tools.rs
vendored
Normal file
457
vendor/ruvector/crates/mcp-gate/src/tools.rs
vendored
Normal 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");
|
||||
}
|
||||
}
|
||||
389
vendor/ruvector/crates/mcp-gate/src/types.rs
vendored
Normal file
389
vendor/ruvector/crates/mcp-gate/src/types.rs
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user