1199 lines
42 KiB
Rust
1199 lines
42 KiB
Rust
//! Browser-Based MCP (Model Context Protocol) for Edge-Net
|
|
//!
|
|
//! Exposes edge-net capabilities as MCP tools accessible from browsers.
|
|
//! Uses MessagePort/BroadcastChannel for cross-context communication.
|
|
//!
|
|
//! ## Usage in JavaScript
|
|
//!
|
|
//! ```javascript
|
|
//! import init, { WasmMcpServer } from '@ruvector/edge-net';
|
|
//!
|
|
//! await init();
|
|
//!
|
|
//! // Create MCP server
|
|
//! const mcp = new WasmMcpServer();
|
|
//!
|
|
//! // Handle MCP requests
|
|
//! const response = await mcp.handleRequest({
|
|
//! jsonrpc: "2.0",
|
|
//! id: 1,
|
|
//! method: "tools/call",
|
|
//! params: {
|
|
//! name: "vector_search",
|
|
//! arguments: { query: [0.1, 0.2, 0.3], k: 10 }
|
|
//! }
|
|
//! });
|
|
//!
|
|
//! // Or use with a WebWorker
|
|
//! const worker = new Worker('edge-net-worker.js');
|
|
//! mcp.attachToWorker(worker);
|
|
//! ```
|
|
|
|
mod protocol;
|
|
mod handlers;
|
|
mod transport;
|
|
|
|
pub use protocol::*;
|
|
pub use handlers::*;
|
|
pub use transport::*;
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use std::sync::Arc;
|
|
use parking_lot::RwLock;
|
|
|
|
use crate::identity::WasmNodeIdentity;
|
|
use crate::credits::WasmCreditLedger;
|
|
use crate::rac::CoherenceEngine;
|
|
use crate::learning::NetworkLearning;
|
|
|
|
/// Security constants
|
|
const MAX_PAYLOAD_SIZE: usize = 1_048_576; // 1MB max payload
|
|
const RATE_LIMIT_WINDOW_MS: u64 = 1000; // 1 second window
|
|
const RATE_LIMIT_MAX_REQUESTS: u64 = 100; // max 100 requests per window
|
|
const MAX_VECTOR_K: usize = 100; // max k for vector searches
|
|
|
|
/// Browser-based MCP server for edge-net
|
|
///
|
|
/// Provides Model Context Protocol interface over MessagePort or direct calls.
|
|
/// All edge-net capabilities are exposed as MCP tools.
|
|
#[wasm_bindgen]
|
|
pub struct WasmMcpServer {
|
|
/// Identity for signing responses
|
|
identity: Option<WasmNodeIdentity>,
|
|
/// Credit ledger for economic operations
|
|
ledger: Arc<RwLock<WasmCreditLedger>>,
|
|
/// RAC coherence engine
|
|
coherence: Arc<RwLock<CoherenceEngine>>,
|
|
/// Learning engine for patterns
|
|
learning: Option<NetworkLearning>,
|
|
/// Server configuration
|
|
config: McpServerConfig,
|
|
/// Request counter for IDs
|
|
request_counter: Arc<RwLock<u64>>,
|
|
/// Rate limiting: (window_start_ms, request_count)
|
|
rate_limit: Arc<RwLock<(u64, u64)>>,
|
|
}
|
|
|
|
/// MCP server configuration
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct McpServerConfig {
|
|
/// Server name
|
|
pub name: String,
|
|
/// Protocol version
|
|
pub version: String,
|
|
/// Enable debug logging
|
|
pub debug: bool,
|
|
/// Maximum concurrent requests
|
|
pub max_concurrent: usize,
|
|
/// Maximum payload size in bytes
|
|
pub max_payload_size: usize,
|
|
/// Rate limit: max requests per second
|
|
pub rate_limit_per_second: u64,
|
|
/// Require authentication for credit operations
|
|
pub require_auth_for_credits: bool,
|
|
}
|
|
|
|
impl Default for McpServerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
name: "edge-net-mcp".to_string(),
|
|
version: "2024-11-05".to_string(),
|
|
debug: false,
|
|
max_concurrent: 16,
|
|
max_payload_size: MAX_PAYLOAD_SIZE,
|
|
rate_limit_per_second: RATE_LIMIT_MAX_REQUESTS,
|
|
require_auth_for_credits: true, // Secure by default
|
|
}
|
|
}
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl WasmMcpServer {
|
|
/// Create a new MCP server with default configuration
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new() -> Result<WasmMcpServer, JsValue> {
|
|
#[cfg(feature = "console_error_panic_hook")]
|
|
console_error_panic_hook::set_once();
|
|
|
|
// Generate a temporary node ID for the ledger
|
|
let node_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
Ok(Self {
|
|
identity: None,
|
|
ledger: Arc::new(RwLock::new(WasmCreditLedger::new(node_id).map_err(|e| e)?)),
|
|
coherence: Arc::new(RwLock::new(CoherenceEngine::new())),
|
|
learning: None,
|
|
config: McpServerConfig::default(),
|
|
request_counter: Arc::new(RwLock::new(0)),
|
|
rate_limit: Arc::new(RwLock::new((0, 0))),
|
|
})
|
|
}
|
|
|
|
/// Create with custom configuration
|
|
#[wasm_bindgen(js_name = withConfig)]
|
|
pub fn with_config(config: JsValue) -> Result<WasmMcpServer, JsValue> {
|
|
let config: McpServerConfig = serde_wasm_bindgen::from_value(config)?;
|
|
|
|
let mut server = Self::new()?;
|
|
server.config = config;
|
|
Ok(server)
|
|
}
|
|
|
|
/// Set identity for authenticated operations
|
|
#[wasm_bindgen(js_name = setIdentity)]
|
|
pub fn set_identity(&mut self, identity: WasmNodeIdentity) {
|
|
self.identity = Some(identity);
|
|
}
|
|
|
|
/// Initialize learning engine
|
|
#[wasm_bindgen(js_name = initLearning)]
|
|
pub fn init_learning(&mut self) -> Result<(), JsValue> {
|
|
self.learning = Some(NetworkLearning::new());
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle an MCP request (JSON string)
|
|
#[wasm_bindgen(js_name = handleRequest)]
|
|
pub async fn handle_request(&self, request_json: &str) -> Result<String, JsValue> {
|
|
// SECURITY: Check payload size before parsing (prevent DoS)
|
|
if request_json.len() > self.config.max_payload_size {
|
|
return Err(JsValue::from_str(&format!(
|
|
"Payload too large: {} bytes exceeds {} limit",
|
|
request_json.len(),
|
|
self.config.max_payload_size
|
|
)));
|
|
}
|
|
|
|
// SECURITY: Check rate limit
|
|
if let Err(e) = self.check_rate_limit() {
|
|
return Err(JsValue::from_str(&e));
|
|
}
|
|
|
|
let request: McpRequest = serde_json::from_str(request_json)
|
|
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
|
|
|
let response = self.process_request(request).await;
|
|
|
|
serde_json::to_string(&response)
|
|
.map_err(|e| JsValue::from_str(&format!("Serialize error: {}", e)))
|
|
}
|
|
|
|
/// Handle MCP request from JsValue (for direct JS calls)
|
|
#[wasm_bindgen(js_name = handleRequestJs)]
|
|
pub async fn handle_request_js(&self, request: JsValue) -> Result<JsValue, JsValue> {
|
|
let request: McpRequest = serde_wasm_bindgen::from_value(request)?;
|
|
let response = self.process_request(request).await;
|
|
serde_wasm_bindgen::to_value(&response)
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Check rate limit - returns error if limit exceeded
|
|
fn check_rate_limit(&self) -> Result<(), String> {
|
|
let now = js_sys::Date::now() as u64;
|
|
let mut rate_limit = self.rate_limit.write();
|
|
|
|
let (window_start, count) = *rate_limit;
|
|
|
|
// Check if we're in a new window
|
|
if now - window_start > RATE_LIMIT_WINDOW_MS {
|
|
// New window
|
|
*rate_limit = (now, 1);
|
|
Ok(())
|
|
} else if count >= self.config.rate_limit_per_second {
|
|
// Rate limit exceeded
|
|
Err(format!(
|
|
"Rate limit exceeded: {} requests per second",
|
|
self.config.rate_limit_per_second
|
|
))
|
|
} else {
|
|
// Increment counter
|
|
*rate_limit = (window_start, count + 1);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Check if identity is set (for authenticated operations)
|
|
fn require_identity(&self) -> Result<&WasmNodeIdentity, McpError> {
|
|
self.identity.as_ref().ok_or_else(|| {
|
|
McpError::new(
|
|
ErrorCodes::INVALID_PARAMS,
|
|
"Authentication required: set identity with setIdentity() first",
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Process MCP request internally
|
|
async fn process_request(&self, request: McpRequest) -> McpResponse {
|
|
// Increment request counter
|
|
{
|
|
let mut counter = self.request_counter.write();
|
|
*counter += 1;
|
|
}
|
|
|
|
match request.method.as_str() {
|
|
"initialize" => self.handle_initialize(request.id),
|
|
"tools/list" => self.handle_tools_list(request.id),
|
|
"tools/call" => self.handle_tools_call(request.id, request.params).await,
|
|
"resources/list" => self.handle_resources_list(request.id),
|
|
"resources/read" => self.handle_resources_read(request.id, request.params),
|
|
"prompts/list" => self.handle_prompts_list(request.id),
|
|
"prompts/get" => self.handle_prompts_get(request.id, request.params),
|
|
_ => McpResponse::error(
|
|
request.id,
|
|
McpError::new(ErrorCodes::METHOD_NOT_FOUND, "Method not found"),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Handle initialize request
|
|
fn handle_initialize(&self, id: Option<Value>) -> McpResponse {
|
|
McpResponse::success(
|
|
id,
|
|
json!({
|
|
"protocolVersion": self.config.version,
|
|
"capabilities": {
|
|
"tools": {
|
|
"listChanged": true
|
|
},
|
|
"resources": {
|
|
"subscribe": true,
|
|
"listChanged": true
|
|
},
|
|
"prompts": {
|
|
"listChanged": true
|
|
},
|
|
"logging": {}
|
|
},
|
|
"serverInfo": {
|
|
"name": self.config.name,
|
|
"version": env!("CARGO_PKG_VERSION")
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Handle tools/list request
|
|
fn handle_tools_list(&self, id: Option<Value>) -> McpResponse {
|
|
let tools = self.get_available_tools();
|
|
McpResponse::success(id, json!({ "tools": tools }))
|
|
}
|
|
|
|
/// Handle tools/call request
|
|
async fn handle_tools_call(&self, id: Option<Value>, params: Option<Value>) -> McpResponse {
|
|
let params = match params {
|
|
Some(p) => p,
|
|
None => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Missing params"),
|
|
),
|
|
};
|
|
|
|
let tool_name = params.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
let arguments = params.get("arguments")
|
|
.cloned()
|
|
.unwrap_or(json!({}));
|
|
|
|
match tool_name {
|
|
// Identity tools
|
|
"identity_generate" => self.tool_identity_generate(id, arguments),
|
|
"identity_sign" => self.tool_identity_sign(id, arguments),
|
|
"identity_verify" => self.tool_identity_verify(id, arguments),
|
|
|
|
// Credit/Economic tools
|
|
"credits_balance" => self.tool_credits_balance(id, arguments),
|
|
"credits_contribute" => self.tool_credits_contribute(id, arguments),
|
|
"credits_spend" => self.tool_credits_spend(id, arguments),
|
|
"credits_health" => self.tool_credits_health(id),
|
|
|
|
// RAC/Coherence tools
|
|
"rac_ingest" => self.tool_rac_ingest(id, arguments),
|
|
"rac_stats" => self.tool_rac_stats(id),
|
|
"rac_merkle_root" => self.tool_rac_merkle_root(id),
|
|
|
|
// Learning tools
|
|
"learning_store_pattern" => self.tool_learning_store(id, arguments),
|
|
"learning_lookup" => self.tool_learning_lookup(id, arguments),
|
|
"learning_stats" => self.tool_learning_stats(id),
|
|
|
|
// Task tools
|
|
"task_submit" => self.tool_task_submit(id, arguments).await,
|
|
"task_status" => self.tool_task_status(id, arguments),
|
|
|
|
// Network tools
|
|
"network_peers" => self.tool_network_peers(id),
|
|
"network_stats" => self.tool_network_stats(id),
|
|
|
|
_ => McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::METHOD_NOT_FOUND, format!("Unknown tool: {}", tool_name)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Handle resources/list request
|
|
fn handle_resources_list(&self, id: Option<Value>) -> McpResponse {
|
|
let resources = vec![
|
|
McpResource {
|
|
uri: "edge-net://identity".to_string(),
|
|
name: "Node Identity".to_string(),
|
|
description: "Current node identity and public key".to_string(),
|
|
mime_type: "application/json".to_string(),
|
|
},
|
|
McpResource {
|
|
uri: "edge-net://ledger".to_string(),
|
|
name: "Credit Ledger".to_string(),
|
|
description: "CRDT-based credit ledger state".to_string(),
|
|
mime_type: "application/json".to_string(),
|
|
},
|
|
McpResource {
|
|
uri: "edge-net://coherence".to_string(),
|
|
name: "RAC State".to_string(),
|
|
description: "Adversarial coherence protocol state".to_string(),
|
|
mime_type: "application/json".to_string(),
|
|
},
|
|
McpResource {
|
|
uri: "edge-net://learning".to_string(),
|
|
name: "Learning Patterns".to_string(),
|
|
description: "Stored learning patterns and trajectories".to_string(),
|
|
mime_type: "application/json".to_string(),
|
|
},
|
|
];
|
|
|
|
McpResponse::success(id, json!({ "resources": resources }))
|
|
}
|
|
|
|
/// Handle resources/read request
|
|
fn handle_resources_read(&self, id: Option<Value>, params: Option<Value>) -> McpResponse {
|
|
let uri = params
|
|
.as_ref()
|
|
.and_then(|p| p.get("uri"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
match uri {
|
|
"edge-net://identity" => {
|
|
let content = match &self.identity {
|
|
Some(id) => json!({
|
|
"nodeId": id.node_id(),
|
|
"siteId": id.site_id(),
|
|
"publicKey": id.public_key_hex(),
|
|
}),
|
|
None => json!({ "status": "not_initialized" }),
|
|
};
|
|
McpResponse::success(id, json!({
|
|
"contents": [{
|
|
"uri": uri,
|
|
"mimeType": "application/json",
|
|
"text": content.to_string()
|
|
}]
|
|
}))
|
|
}
|
|
"edge-net://ledger" => {
|
|
let ledger = self.ledger.read();
|
|
let stats = json!({
|
|
"balance": ledger.balance(),
|
|
"totalEarned": ledger.total_earned(),
|
|
"totalSpent": ledger.total_spent(),
|
|
});
|
|
McpResponse::success(id, json!({
|
|
"contents": [{
|
|
"uri": uri,
|
|
"mimeType": "application/json",
|
|
"text": stats.to_string()
|
|
}]
|
|
}))
|
|
}
|
|
"edge-net://coherence" => {
|
|
let coherence = self.coherence.read();
|
|
let stats = json!({
|
|
"eventCount": coherence.event_count(),
|
|
"conflictCount": coherence.conflict_count(),
|
|
"quarantinedCount": coherence.quarantined_count(),
|
|
});
|
|
McpResponse::success(id, json!({
|
|
"contents": [{
|
|
"uri": uri,
|
|
"mimeType": "application/json",
|
|
"text": stats.to_string()
|
|
}]
|
|
}))
|
|
}
|
|
_ => McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, format!("Unknown resource: {}", uri)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Handle prompts/list request
|
|
fn handle_prompts_list(&self, id: Option<Value>) -> McpResponse {
|
|
let prompts = vec![
|
|
McpPrompt {
|
|
name: "analyze_network".to_string(),
|
|
description: "Analyze edge-net network health and suggest optimizations".to_string(),
|
|
arguments: Some(vec![
|
|
PromptArgument {
|
|
name: "focus".to_string(),
|
|
description: "Focus area: performance, security, or economics".to_string(),
|
|
required: false,
|
|
}
|
|
]),
|
|
},
|
|
McpPrompt {
|
|
name: "debug_coherence".to_string(),
|
|
description: "Debug RAC coherence issues and conflicts".to_string(),
|
|
arguments: None,
|
|
},
|
|
];
|
|
|
|
McpResponse::success(id, json!({ "prompts": prompts }))
|
|
}
|
|
|
|
/// Handle prompts/get request
|
|
fn handle_prompts_get(&self, id: Option<Value>, params: Option<Value>) -> McpResponse {
|
|
let name = params
|
|
.as_ref()
|
|
.and_then(|p| p.get("name"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
match name {
|
|
"analyze_network" => {
|
|
let coherence = self.coherence.read();
|
|
let ledger = self.ledger.read();
|
|
|
|
McpResponse::success(id, json!({
|
|
"description": "Network analysis prompt",
|
|
"messages": [{
|
|
"role": "user",
|
|
"content": {
|
|
"type": "text",
|
|
"text": format!(
|
|
"Analyze this edge-net node:\n\
|
|
- Events: {}\n\
|
|
- Conflicts: {}\n\
|
|
- Balance: {} credits\n\
|
|
- Earned: {} | Spent: {}\n\n\
|
|
Suggest optimizations for performance and reliability.",
|
|
coherence.event_count(),
|
|
coherence.conflict_count(),
|
|
ledger.balance(),
|
|
ledger.total_earned(),
|
|
ledger.total_spent()
|
|
)
|
|
}
|
|
}]
|
|
}))
|
|
}
|
|
_ => McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, format!("Unknown prompt: {}", name)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Get list of available tools
|
|
fn get_available_tools(&self) -> Vec<McpTool> {
|
|
vec![
|
|
// Identity tools
|
|
McpTool {
|
|
name: "identity_generate".to_string(),
|
|
description: "Generate a new node identity with Ed25519 keypair".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"site_id": { "type": "string", "description": "Site identifier" }
|
|
},
|
|
"required": ["site_id"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "identity_sign".to_string(),
|
|
description: "Sign a message with the node's private key".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": { "type": "string", "description": "Message to sign (base64)" }
|
|
},
|
|
"required": ["message"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "identity_verify".to_string(),
|
|
description: "Verify a signature from any node".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"public_key": { "type": "string", "description": "Public key (hex)" },
|
|
"message": { "type": "string", "description": "Original message (base64)" },
|
|
"signature": { "type": "string", "description": "Signature (hex)" }
|
|
},
|
|
"required": ["public_key", "message", "signature"]
|
|
}),
|
|
},
|
|
|
|
// Credit tools
|
|
McpTool {
|
|
name: "credits_balance".to_string(),
|
|
description: "Get credit balance for a node".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"node_id": { "type": "string", "description": "Node ID to check" }
|
|
},
|
|
"required": ["node_id"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "credits_contribute".to_string(),
|
|
description: "Record a compute contribution and earn credits".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"amount": { "type": "number", "description": "Contribution amount" },
|
|
"task_type": { "type": "string", "description": "Type of task completed" }
|
|
},
|
|
"required": ["amount"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "credits_spend".to_string(),
|
|
description: "Spend credits on a task".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"amount": { "type": "number", "description": "Amount to spend" },
|
|
"purpose": { "type": "string", "description": "What the credits are for" }
|
|
},
|
|
"required": ["amount"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "credits_health".to_string(),
|
|
description: "Get economic health metrics for the network".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
}),
|
|
},
|
|
|
|
// RAC tools
|
|
McpTool {
|
|
name: "rac_ingest".to_string(),
|
|
description: "Ingest an event into the coherence engine".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"event": { "type": "object", "description": "Event to ingest" }
|
|
},
|
|
"required": ["event"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "rac_stats".to_string(),
|
|
description: "Get RAC coherence statistics".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "rac_merkle_root".to_string(),
|
|
description: "Get current Merkle root of event log".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
}),
|
|
},
|
|
|
|
// Learning tools
|
|
McpTool {
|
|
name: "learning_store_pattern".to_string(),
|
|
description: "Store a learned pattern with embedding".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"embedding": { "type": "array", "items": { "type": "number" } },
|
|
"metadata": { "type": "object" }
|
|
},
|
|
"required": ["embedding"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "learning_lookup".to_string(),
|
|
description: "Lookup similar patterns".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"query": { "type": "array", "items": { "type": "number" } },
|
|
"k": { "type": "integer", "default": 5 }
|
|
},
|
|
"required": ["query"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "learning_stats".to_string(),
|
|
description: "Get learning engine statistics".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
}),
|
|
},
|
|
|
|
// Task tools
|
|
McpTool {
|
|
name: "task_submit".to_string(),
|
|
description: "Submit a compute task to the network".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"task_type": {
|
|
"type": "string",
|
|
"enum": ["vector_search", "embedding", "semantic_match", "neural", "encryption", "compression"]
|
|
},
|
|
"payload": { "type": "object" },
|
|
"max_cost": { "type": "number" }
|
|
},
|
|
"required": ["task_type", "payload"]
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "task_status".to_string(),
|
|
description: "Check status of a submitted task".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"task_id": { "type": "string" }
|
|
},
|
|
"required": ["task_id"]
|
|
}),
|
|
},
|
|
|
|
// Network tools
|
|
McpTool {
|
|
name: "network_peers".to_string(),
|
|
description: "Get list of connected peers".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
}),
|
|
},
|
|
McpTool {
|
|
name: "network_stats".to_string(),
|
|
description: "Get network statistics".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {}
|
|
}),
|
|
},
|
|
]
|
|
}
|
|
|
|
// ========================================================================
|
|
// Tool Implementations
|
|
// ========================================================================
|
|
|
|
fn tool_identity_generate(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let site_id = args.get("site_id")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("default");
|
|
|
|
match WasmNodeIdentity::generate(site_id) {
|
|
Ok(identity) => {
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!(
|
|
"Generated identity:\n- Node ID: {}\n- Public Key: {}",
|
|
identity.node_id(),
|
|
identity.public_key_hex()
|
|
)
|
|
}],
|
|
"nodeId": identity.node_id(),
|
|
"publicKey": identity.public_key_hex()
|
|
}))
|
|
}
|
|
Err(e) => McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INTERNAL_ERROR, format!("Failed to generate identity: {:?}", e)),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn tool_identity_sign(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let identity = match &self.identity {
|
|
Some(i) => i,
|
|
None => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "No identity set"),
|
|
),
|
|
};
|
|
|
|
let message_b64 = args.get("message")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
let message = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) {
|
|
Ok(m) => m,
|
|
Err(e) => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, format!("Invalid base64: {}", e)),
|
|
),
|
|
};
|
|
|
|
let signature = identity.sign(&message);
|
|
let sig_hex = hex::encode(&signature);
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Signature: {}", sig_hex)
|
|
}],
|
|
"signature": sig_hex
|
|
}))
|
|
}
|
|
|
|
fn tool_identity_verify(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let public_key_hex = args.get("public_key").and_then(|v| v.as_str()).unwrap_or("");
|
|
let message_b64 = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
|
let signature_hex = args.get("signature").and_then(|v| v.as_str()).unwrap_or("");
|
|
|
|
let public_key = match hex::decode(public_key_hex) {
|
|
Ok(k) => k,
|
|
Err(e) => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, format!("Invalid public key hex: {}", e)),
|
|
),
|
|
};
|
|
|
|
let message = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) {
|
|
Ok(m) => m,
|
|
Err(e) => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, format!("Invalid message base64: {}", e)),
|
|
),
|
|
};
|
|
|
|
let signature = match hex::decode(signature_hex) {
|
|
Ok(s) => s,
|
|
Err(e) => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, format!("Invalid signature hex: {}", e)),
|
|
),
|
|
};
|
|
|
|
let valid = WasmNodeIdentity::verify_from(&public_key, &message, &signature);
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": if valid { "Signature is valid ✓" } else { "Signature is INVALID ✗" }
|
|
}],
|
|
"valid": valid
|
|
}))
|
|
}
|
|
|
|
fn tool_credits_balance(&self, id: Option<Value>, _args: Value) -> McpResponse {
|
|
let ledger = self.ledger.read();
|
|
let balance = ledger.balance();
|
|
let earned = ledger.total_earned();
|
|
let spent = ledger.total_spent();
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Balance: {} rUv (earned: {}, spent: {})", balance, earned, spent)
|
|
}],
|
|
"balance": balance,
|
|
"totalEarned": earned,
|
|
"totalSpent": spent
|
|
}))
|
|
}
|
|
|
|
fn tool_credits_contribute(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
// SECURITY: Require authentication for credit operations
|
|
if self.config.require_auth_for_credits {
|
|
if let Err(e) = self.require_identity() {
|
|
return McpResponse::error(id, e);
|
|
}
|
|
}
|
|
|
|
let amount = args.get("amount")
|
|
.and_then(|v| v.as_f64())
|
|
.unwrap_or(0.0);
|
|
|
|
// SECURITY: Validate amount bounds
|
|
if amount < 0.0 || amount > u64::MAX as f64 {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Invalid amount: must be non-negative"),
|
|
);
|
|
}
|
|
let amount = amount as u64;
|
|
|
|
// SECURITY: Limit max credit per transaction
|
|
const MAX_CREDIT_PER_TX: u64 = 1_000_000;
|
|
if amount > MAX_CREDIT_PER_TX {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS,
|
|
format!("Amount {} exceeds max {} per transaction", amount, MAX_CREDIT_PER_TX)),
|
|
);
|
|
}
|
|
|
|
let task_type = args.get("task_type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("general");
|
|
|
|
let mut ledger = self.ledger.write();
|
|
if let Err(e) = ledger.credit(amount, task_type) {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INTERNAL_ERROR, "Credit operation failed"),
|
|
);
|
|
}
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Contributed {} rUv for {} task", amount, task_type)
|
|
}],
|
|
"credited": amount,
|
|
"newBalance": ledger.balance()
|
|
}))
|
|
}
|
|
|
|
fn tool_credits_spend(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
// SECURITY: Require authentication for credit operations
|
|
if self.config.require_auth_for_credits {
|
|
if let Err(e) = self.require_identity() {
|
|
return McpResponse::error(id, e);
|
|
}
|
|
}
|
|
|
|
let amount = args.get("amount")
|
|
.and_then(|v| v.as_f64())
|
|
.unwrap_or(0.0);
|
|
|
|
// SECURITY: Validate amount bounds
|
|
if amount < 0.0 || amount > u64::MAX as f64 {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Invalid amount"),
|
|
);
|
|
}
|
|
let amount = amount as u64;
|
|
|
|
let purpose = args.get("purpose")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("task");
|
|
|
|
let mut ledger = self.ledger.write();
|
|
let current_balance = ledger.balance();
|
|
|
|
if current_balance < amount {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Insufficient balance"),
|
|
);
|
|
}
|
|
|
|
if let Err(_) = ledger.deduct(amount) {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INTERNAL_ERROR, "Deduct operation failed"),
|
|
);
|
|
}
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Spent {} rUv on {}", amount, purpose)
|
|
}],
|
|
"spent": amount,
|
|
"newBalance": ledger.balance(),
|
|
"purpose": purpose
|
|
}))
|
|
}
|
|
|
|
fn tool_credits_health(&self, id: Option<Value>) -> McpResponse {
|
|
let ledger = self.ledger.read();
|
|
let balance = ledger.balance();
|
|
let earned = ledger.total_earned();
|
|
let spent = ledger.total_spent();
|
|
let staked = ledger.staked_amount();
|
|
let multiplier = ledger.current_multiplier();
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!(
|
|
"Economic Health:\n- Balance: {} rUv\n- Earned: {}\n- Spent: {}\n- Staked: {}\n- Multiplier: {}x",
|
|
balance, earned, spent, staked, multiplier
|
|
)
|
|
}],
|
|
"balance": balance,
|
|
"totalEarned": earned,
|
|
"totalSpent": spent,
|
|
"staked": staked,
|
|
"multiplier": multiplier
|
|
}))
|
|
}
|
|
|
|
fn tool_rac_ingest(&self, id: Option<Value>, _args: Value) -> McpResponse {
|
|
// Simplified - would parse event from args
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": "Event ingestion requires proper Event struct parsing"
|
|
}],
|
|
"status": "not_implemented"
|
|
}))
|
|
}
|
|
|
|
fn tool_rac_stats(&self, id: Option<Value>) -> McpResponse {
|
|
let coherence = self.coherence.read();
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!(
|
|
"RAC Statistics:\n- Events: {}\n- Conflicts: {}\n- Quarantined: {}",
|
|
coherence.event_count(),
|
|
coherence.conflict_count(),
|
|
coherence.quarantined_count()
|
|
)
|
|
}],
|
|
"eventCount": coherence.event_count(),
|
|
"conflictCount": coherence.conflict_count(),
|
|
"quarantinedCount": coherence.quarantined_count()
|
|
}))
|
|
}
|
|
|
|
fn tool_rac_merkle_root(&self, id: Option<Value>) -> McpResponse {
|
|
let coherence = self.coherence.read();
|
|
let root = coherence.get_merkle_root();
|
|
let root_hex = hex::encode(&root);
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Merkle Root: {}", root_hex)
|
|
}],
|
|
"merkleRoot": root_hex
|
|
}))
|
|
}
|
|
|
|
fn tool_learning_store(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let learning = match &self.learning {
|
|
Some(l) => l,
|
|
None => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Learning engine not initialized"),
|
|
),
|
|
};
|
|
|
|
// The learning engine expects a JSON string with pattern data
|
|
let pattern_json = serde_json::to_string(&args).unwrap_or_default();
|
|
|
|
let pattern_id = learning.store_pattern(&pattern_json);
|
|
|
|
if pattern_id < 0 {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Invalid pattern format"),
|
|
);
|
|
}
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Stored pattern with ID {}", pattern_id)
|
|
}],
|
|
"patternId": pattern_id
|
|
}))
|
|
}
|
|
|
|
fn tool_learning_lookup(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let learning = match &self.learning {
|
|
Some(l) => l,
|
|
None => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Learning engine not initialized"),
|
|
),
|
|
};
|
|
|
|
let query: Vec<f32> = args.get("query")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| arr.iter().filter_map(|v| v.as_f64().map(|f| f as f32)).collect())
|
|
.unwrap_or_default();
|
|
|
|
let k = args.get("k")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(5) as usize;
|
|
|
|
// SECURITY: Limit k to prevent memory exhaustion
|
|
let k = k.min(MAX_VECTOR_K);
|
|
|
|
if query.is_empty() {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Empty query"),
|
|
);
|
|
}
|
|
|
|
// SECURITY: Validate vector dimensions (prevent NaN/Infinity)
|
|
for val in &query {
|
|
if !val.is_finite() {
|
|
return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Invalid vector values"),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Convert query to JSON for the learning engine
|
|
let query_json = serde_json::to_string(&query).unwrap_or("[]".to_string());
|
|
let results = learning.lookup_patterns(&query_json, k);
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Found {} similar patterns", results.len())
|
|
}],
|
|
"results": results
|
|
}))
|
|
}
|
|
|
|
fn tool_learning_stats(&self, id: Option<Value>) -> McpResponse {
|
|
let learning = match &self.learning {
|
|
Some(l) => l,
|
|
None => return McpResponse::error(
|
|
id,
|
|
McpError::new(ErrorCodes::INVALID_PARAMS, "Learning engine not initialized"),
|
|
),
|
|
};
|
|
|
|
let stats = learning.get_stats();
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Learning Stats:\n{}", stats)
|
|
}],
|
|
"stats": stats
|
|
}))
|
|
}
|
|
|
|
async fn tool_task_submit(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let task_type = args.get("task_type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("general");
|
|
|
|
let _payload = args.get("payload").cloned().unwrap_or(json!({}));
|
|
let max_cost = args.get("max_cost")
|
|
.and_then(|v| v.as_f64())
|
|
.unwrap_or(10.0) as u64;
|
|
|
|
// Generate task ID
|
|
let task_id = format!("task-{}", uuid::Uuid::new_v4());
|
|
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Task {} submitted (type: {}, max_cost: {} rUv)", task_id, task_type, max_cost)
|
|
}],
|
|
"taskId": task_id,
|
|
"status": "queued",
|
|
"estimatedCost": max_cost / 2
|
|
}))
|
|
}
|
|
|
|
fn tool_task_status(&self, id: Option<Value>, args: Value) -> McpResponse {
|
|
let task_id = args.get("task_id")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
// Would look up actual task status
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("Task {} status: pending", task_id)
|
|
}],
|
|
"taskId": task_id,
|
|
"status": "pending",
|
|
"progress": 0.0
|
|
}))
|
|
}
|
|
|
|
fn tool_network_peers(&self, id: Option<Value>) -> McpResponse {
|
|
// Would return actual connected peers
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": "Connected peers: 0 (P2P not yet implemented)"
|
|
}],
|
|
"peers": [],
|
|
"count": 0
|
|
}))
|
|
}
|
|
|
|
fn tool_network_stats(&self, id: Option<Value>) -> McpResponse {
|
|
McpResponse::success(id, json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": "Network stats:\n- Connected: false\n- Peers: 0"
|
|
}],
|
|
"connected": false,
|
|
"peerCount": 0,
|
|
"messagesSent": 0,
|
|
"messagesReceived": 0
|
|
}))
|
|
}
|
|
|
|
/// Get server info
|
|
#[wasm_bindgen(js_name = getServerInfo)]
|
|
pub fn get_server_info(&self) -> JsValue {
|
|
let info = json!({
|
|
"name": self.config.name,
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"protocolVersion": self.config.version,
|
|
"toolCount": self.get_available_tools().len(),
|
|
"hasIdentity": self.identity.is_some(),
|
|
"hasLearning": self.learning.is_some()
|
|
});
|
|
|
|
JsValue::from_str(&info.to_string())
|
|
}
|
|
}
|
|
|
|
impl Default for WasmMcpServer {
|
|
fn default() -> Self {
|
|
Self::new().expect("Failed to create default MCP server")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_mcp_server_creation() {
|
|
let server = WasmMcpServer::new().unwrap();
|
|
assert!(!server.config.name.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tools_list() {
|
|
let server = WasmMcpServer::new().unwrap();
|
|
let tools = server.get_available_tools();
|
|
assert!(!tools.is_empty());
|
|
assert!(tools.iter().any(|t| t.name == "credits_balance"));
|
|
}
|
|
}
|