Apply recent changes
This commit is contained in:
@@ -17,7 +17,19 @@ pub struct RemoteMcpClient;
|
||||
|
||||
impl RemoteMcpClient {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self)
|
||||
// Attempt to spawn the MCP server binary located at ./target/debug/owlen-mcp-server
|
||||
// The server runs over STDIO and will be managed by the client instance.
|
||||
// For now we just verify that the binary exists; the actual process handling
|
||||
// is performed lazily in the async methods.
|
||||
let path = "./target/debug/owlen-mcp-server";
|
||||
if std::path::Path::new(path).exists() {
|
||||
Ok(Self)
|
||||
} else {
|
||||
Err(Error::NotImplemented(format!(
|
||||
"Remote MCP server binary not found at {}",
|
||||
path
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
87
crates/owlen-core/src/mcp/factory.rs
Normal file
87
crates/owlen-core/src/mcp/factory.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
/// MCP Client Factory
|
||||
///
|
||||
/// Provides a unified interface for creating MCP clients based on configuration.
|
||||
/// Supports switching between local (in-process) and remote (STDIO) execution modes.
|
||||
use super::client::McpClient;
|
||||
use super::{remote_client::RemoteMcpClient, LocalMcpClient};
|
||||
use crate::config::{Config, McpMode};
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::validation::SchemaValidator;
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Factory for creating MCP clients based on configuration
|
||||
pub struct McpClientFactory {
|
||||
config: Arc<Config>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
validator: Arc<SchemaValidator>,
|
||||
}
|
||||
|
||||
impl McpClientFactory {
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
validator: Arc<SchemaValidator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
registry,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an MCP client based on the current configuration
|
||||
pub fn create(&self) -> Result<Box<dyn McpClient>> {
|
||||
match self.config.mcp.mode {
|
||||
McpMode::Legacy => {
|
||||
// Use local in-process client
|
||||
Ok(Box::new(LocalMcpClient::new(
|
||||
self.registry.clone(),
|
||||
self.validator.clone(),
|
||||
)))
|
||||
}
|
||||
McpMode::Enabled => {
|
||||
// Attempt to use remote client, fall back to local if unavailable
|
||||
match RemoteMcpClient::new() {
|
||||
Ok(client) => Ok(Box::new(client)),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to start remote MCP client: {}. Falling back to local mode.", e);
|
||||
Ok(Box::new(LocalMcpClient::new(
|
||||
self.registry.clone(),
|
||||
self.validator.clone(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if remote MCP mode is available
|
||||
pub fn is_remote_available() -> bool {
|
||||
RemoteMcpClient::new().is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_factory_creates_local_client_in_legacy_mode() {
|
||||
let mut config = Config::default();
|
||||
config.mcp.mode = McpMode::Legacy;
|
||||
|
||||
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||
let registry = Arc::new(ToolRegistry::new(
|
||||
Arc::new(tokio::sync::Mutex::new(config.clone())),
|
||||
ui,
|
||||
));
|
||||
let validator = Arc::new(SchemaValidator::new());
|
||||
|
||||
let factory = McpClientFactory::new(Arc::new(config), registry, validator);
|
||||
|
||||
// Should create without error
|
||||
let result = factory.create();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod client;
|
||||
pub mod factory;
|
||||
pub mod permission;
|
||||
pub mod protocol;
|
||||
pub mod remote_client;
|
||||
|
||||
/// Descriptor for a tool exposed over MCP
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
217
crates/owlen-core/src/mcp/permission.rs
Normal file
217
crates/owlen-core/src/mcp/permission.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
/// Permission and Safety Layer for MCP
|
||||
///
|
||||
/// This module provides runtime enforcement of security policies for tool execution.
|
||||
/// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent.
|
||||
use super::client::McpClient;
|
||||
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::config::Config;
|
||||
use crate::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Callback for requesting user consent for dangerous operations
|
||||
pub type ConsentCallback = Arc<dyn Fn(&str, &McpToolCall) -> bool + Send + Sync>;
|
||||
|
||||
/// Callback for logging tool invocations
|
||||
pub type LogCallback = Arc<dyn Fn(&str, &McpToolCall, &Result<McpToolResponse>) + Send + Sync>;
|
||||
|
||||
/// Permission-enforcing wrapper around an MCP client
|
||||
pub struct PermissionLayer {
|
||||
inner: Box<dyn McpClient>,
|
||||
config: Arc<Config>,
|
||||
consent_callback: Option<ConsentCallback>,
|
||||
log_callback: Option<LogCallback>,
|
||||
allowed_tools: HashSet<String>,
|
||||
}
|
||||
|
||||
impl PermissionLayer {
|
||||
/// Create a new permission layer wrapping the given client
|
||||
pub fn new(inner: Box<dyn McpClient>, config: Arc<Config>) -> Self {
|
||||
let allowed_tools = config.security.allowed_tools.iter().cloned().collect();
|
||||
|
||||
Self {
|
||||
inner,
|
||||
config,
|
||||
consent_callback: None,
|
||||
log_callback: None,
|
||||
allowed_tools,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a callback for requesting user consent
|
||||
pub fn with_consent_callback(mut self, callback: ConsentCallback) -> Self {
|
||||
self.consent_callback = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a callback for logging tool invocations
|
||||
pub fn with_log_callback(mut self, callback: LogCallback) -> Self {
|
||||
self.log_callback = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a tool requires dangerous filesystem operations
|
||||
fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"resources/write" | "resources/delete" | "file_write" | "file_delete"
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if a tool is allowed by security policy
|
||||
fn is_tool_allowed(&self, tool_descriptor: &McpToolDescriptor) -> bool {
|
||||
// Check if tool requires filesystem access
|
||||
for fs_perm in &tool_descriptor.requires_filesystem {
|
||||
if !self.allowed_tools.contains(fs_perm) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tool requires network access
|
||||
if tool_descriptor.requires_network && !self.allowed_tools.contains("web_search") {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Request user consent for a tool call
|
||||
fn request_consent(&self, tool_name: &str, call: &McpToolCall) -> bool {
|
||||
if let Some(ref callback) = self.consent_callback {
|
||||
callback(tool_name, call)
|
||||
} else {
|
||||
// If no callback is set, deny dangerous operations by default
|
||||
!self.requires_dangerous_filesystem(tool_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a tool invocation
|
||||
fn log_invocation(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
call: &McpToolCall,
|
||||
result: &Result<McpToolResponse>,
|
||||
) {
|
||||
if let Some(ref callback) = self.log_callback {
|
||||
callback(tool_name, call, result);
|
||||
} else {
|
||||
// Default logging to stderr
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
eprintln!(
|
||||
"[MCP] Tool '{}' executed successfully ({}ms)",
|
||||
tool_name, resp.duration_ms
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[MCP] Tool '{}' failed: {}", tool_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for PermissionLayer {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
let tools = self.inner.list_tools().await?;
|
||||
// Filter tools based on security policy
|
||||
Ok(tools
|
||||
.into_iter()
|
||||
.filter(|tool| self.is_tool_allowed(tool))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
// Check if tool requires consent
|
||||
if self.requires_dangerous_filesystem(&call.name)
|
||||
&& self.config.privacy.require_consent_per_session
|
||||
&& !self.request_consent(&call.name, &call)
|
||||
{
|
||||
let result = Err(Error::PermissionDenied(format!(
|
||||
"User denied consent for tool '{}'",
|
||||
call.name
|
||||
)));
|
||||
self.log_invocation(&call.name, &call, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Execute the tool call
|
||||
let result = self.inner.call_tool(call.clone()).await;
|
||||
|
||||
// Log the invocation
|
||||
self.log_invocation(&call.name, &call, &result);
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::LocalMcpClient;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::validation::SchemaValidator;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_permission_layer_filters_dangerous_tools() {
|
||||
let config = Arc::new(Config::default());
|
||||
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||
let registry = Arc::new(ToolRegistry::new(
|
||||
Arc::new(tokio::sync::Mutex::new((*config).clone())),
|
||||
ui,
|
||||
));
|
||||
let validator = Arc::new(SchemaValidator::new());
|
||||
let client = Box::new(LocalMcpClient::new(registry, validator));
|
||||
|
||||
let mut config_mut = (*config).clone();
|
||||
// Disallow file operations
|
||||
config_mut.security.allowed_tools = vec!["web_search".to_string()];
|
||||
|
||||
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut));
|
||||
|
||||
let tools = permission_layer.list_tools().await.unwrap();
|
||||
|
||||
// Should not include file_write or file_delete tools
|
||||
assert!(!tools.iter().any(|t| t.name.contains("write")));
|
||||
assert!(!tools.iter().any(|t| t.name.contains("delete")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_consent_callback_is_invoked() {
|
||||
let config = Arc::new(Config::default());
|
||||
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||
let registry = Arc::new(ToolRegistry::new(
|
||||
Arc::new(tokio::sync::Mutex::new((*config).clone())),
|
||||
ui,
|
||||
));
|
||||
let validator = Arc::new(SchemaValidator::new());
|
||||
let client = Box::new(LocalMcpClient::new(registry, validator));
|
||||
|
||||
let consent_called = Arc::new(AtomicBool::new(false));
|
||||
let consent_called_clone = consent_called.clone();
|
||||
|
||||
let consent_callback: ConsentCallback = Arc::new(move |_tool, _call| {
|
||||
consent_called_clone.store(true, Ordering::SeqCst);
|
||||
false // Deny
|
||||
});
|
||||
|
||||
let mut config_mut = (*config).clone();
|
||||
config_mut.privacy.require_consent_per_session = true;
|
||||
|
||||
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut))
|
||||
.with_consent_callback(consent_callback);
|
||||
|
||||
let call = McpToolCall {
|
||||
name: "resources/write".to_string(),
|
||||
arguments: serde_json::json!({"path": "test.txt", "content": "hello"}),
|
||||
};
|
||||
|
||||
let result = permission_layer.call_tool(call).await;
|
||||
|
||||
assert!(consent_called.load(Ordering::SeqCst));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
369
crates/owlen-core/src/mcp/protocol.rs
Normal file
369
crates/owlen-core/src/mcp/protocol.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
/// MCP Protocol Definitions
|
||||
///
|
||||
/// This module defines the JSON-RPC protocol contracts for the Model Context Protocol (MCP).
|
||||
/// It includes request/response schemas, error codes, and versioning semantics.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// MCP Protocol version - uses semantic versioning
|
||||
pub const PROTOCOL_VERSION: &str = "1.0.0";
|
||||
|
||||
/// JSON-RPC version constant
|
||||
pub const JSONRPC_VERSION: &str = "2.0";
|
||||
|
||||
// ============================================================================
|
||||
// Error Codes and Handling
|
||||
// ============================================================================
|
||||
|
||||
/// Standard JSON-RPC error codes following the spec
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ErrorCode(pub i64);
|
||||
|
||||
impl ErrorCode {
|
||||
// Standard JSON-RPC 2.0 errors
|
||||
pub const PARSE_ERROR: Self = Self(-32700);
|
||||
pub const INVALID_REQUEST: Self = Self(-32600);
|
||||
pub const METHOD_NOT_FOUND: Self = Self(-32601);
|
||||
pub const INVALID_PARAMS: Self = Self(-32602);
|
||||
pub const INTERNAL_ERROR: Self = Self(-32603);
|
||||
|
||||
// MCP-specific errors (range -32000 to -32099)
|
||||
pub const TOOL_NOT_FOUND: Self = Self(-32000);
|
||||
pub const TOOL_EXECUTION_FAILED: Self = Self(-32001);
|
||||
pub const PERMISSION_DENIED: Self = Self(-32002);
|
||||
pub const RESOURCE_NOT_FOUND: Self = Self(-32003);
|
||||
pub const TIMEOUT: Self = Self(-32004);
|
||||
pub const VALIDATION_ERROR: Self = Self(-32005);
|
||||
pub const PATH_TRAVERSAL: Self = Self(-32006);
|
||||
pub const RATE_LIMIT_EXCEEDED: Self = Self(-32007);
|
||||
}
|
||||
|
||||
/// Structured error response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
impl RpcError {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.0,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_data(mut self, data: Value) -> Self {
|
||||
self.data = Some(data);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::PARSE_ERROR, message)
|
||||
}
|
||||
|
||||
pub fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::INVALID_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn method_not_found(method: &str) -> Self {
|
||||
Self::new(
|
||||
ErrorCode::METHOD_NOT_FOUND,
|
||||
format!("Method not found: {}", method),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::INVALID_PARAMS, message)
|
||||
}
|
||||
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::INTERNAL_ERROR, message)
|
||||
}
|
||||
|
||||
pub fn tool_not_found(tool_name: &str) -> Self {
|
||||
Self::new(
|
||||
ErrorCode::TOOL_NOT_FOUND,
|
||||
format!("Tool not found: {}", tool_name),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn permission_denied(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::PERMISSION_DENIED, message)
|
||||
}
|
||||
|
||||
pub fn path_traversal() -> Self {
|
||||
Self::new(ErrorCode::PATH_TRAVERSAL, "Path traversal attempt detected")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Structures
|
||||
// ============================================================================
|
||||
|
||||
/// JSON-RPC request structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcRequest {
|
||||
pub jsonrpc: String,
|
||||
pub id: RequestId,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
impl RpcRequest {
|
||||
pub fn new(id: RequestId, method: impl Into<String>, params: Option<Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: JSONRPC_VERSION.to_string(),
|
||||
id,
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC response structure (success)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: RequestId,
|
||||
pub result: Value,
|
||||
}
|
||||
|
||||
impl RpcResponse {
|
||||
pub fn new(id: RequestId, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: JSONRPC_VERSION.to_string(),
|
||||
id,
|
||||
result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC error response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcErrorResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: RequestId,
|
||||
pub error: RpcError,
|
||||
}
|
||||
|
||||
impl RpcErrorResponse {
|
||||
pub fn new(id: RequestId, error: RpcError) -> Self {
|
||||
Self {
|
||||
jsonrpc: JSONRPC_VERSION.to_string(),
|
||||
id,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request ID can be string, number, or null
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
Number(u64),
|
||||
String(String),
|
||||
}
|
||||
|
||||
impl From<u64> for RequestId {
|
||||
fn from(n: u64) -> Self {
|
||||
Self::Number(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RequestId {
|
||||
fn from(s: String) -> Self {
|
||||
Self::String(s)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Method Names
|
||||
// ============================================================================
|
||||
|
||||
/// Standard MCP methods
|
||||
pub mod methods {
|
||||
pub const INITIALIZE: &str = "initialize";
|
||||
pub const TOOLS_LIST: &str = "tools/list";
|
||||
pub const TOOLS_CALL: &str = "tools/call";
|
||||
pub const RESOURCES_LIST: &str = "resources/list";
|
||||
pub const RESOURCES_GET: &str = "resources/get";
|
||||
pub const RESOURCES_WRITE: &str = "resources/write";
|
||||
pub const RESOURCES_DELETE: &str = "resources/delete";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initialization Protocol
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize request parameters
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InitializeParams {
|
||||
pub protocol_version: String,
|
||||
pub client_info: ClientInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capabilities: Option<ClientCapabilities>,
|
||||
}
|
||||
|
||||
impl Default for InitializeParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
protocol_version: PROTOCOL_VERSION.to_string(),
|
||||
client_info: ClientInfo {
|
||||
name: "owlen".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Client capabilities
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ClientCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_streaming: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_cancellation: Option<bool>,
|
||||
}
|
||||
|
||||
/// Initialize response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InitializeResult {
|
||||
pub protocol_version: String,
|
||||
pub server_info: ServerInfo,
|
||||
pub capabilities: ServerCapabilities,
|
||||
}
|
||||
|
||||
/// Server information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Server capabilities
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_tools: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_resources: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_streaming: Option<bool>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Call Protocol
|
||||
// ============================================================================
|
||||
|
||||
/// Parameters for tools/list
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolsListParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for tools/call
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolsCallParams {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Value>,
|
||||
}
|
||||
|
||||
/// Result of tools/call
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolsCallResult {
|
||||
pub success: bool,
|
||||
pub output: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resource Protocol
|
||||
// ============================================================================
|
||||
|
||||
/// Parameters for resources/list
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourcesListParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Parameters for resources/get
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourcesGetParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Parameters for resources/write
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourcesWriteParams {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Parameters for resources/delete
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourcesDeleteParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Versioning and Compatibility
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a protocol version is compatible
|
||||
pub fn is_compatible(client_version: &str, server_version: &str) -> bool {
|
||||
// For now, simple exact match on major version
|
||||
let client_major = client_version.split('.').next().unwrap_or("0");
|
||||
let server_major = server_version.split('.').next().unwrap_or("0");
|
||||
client_major == server_major
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_codes() {
|
||||
let err = RpcError::tool_not_found("test_tool");
|
||||
assert_eq!(err.code, ErrorCode::TOOL_NOT_FOUND.0);
|
||||
assert!(err.message.contains("test_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_compatibility() {
|
||||
assert!(is_compatible("1.0.0", "1.0.0"));
|
||||
assert!(is_compatible("1.0.0", "1.1.0"));
|
||||
assert!(is_compatible("1.2.5", "1.0.0"));
|
||||
assert!(!is_compatible("1.0.0", "2.0.0"));
|
||||
assert!(!is_compatible("2.0.0", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_serialization() {
|
||||
let req = RpcRequest::new(
|
||||
RequestId::Number(1),
|
||||
"tools/call",
|
||||
Some(serde_json::json!({"name": "test"})),
|
||||
);
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"method\":\"tools/call\""));
|
||||
}
|
||||
}
|
||||
120
crates/owlen-core/src/mcp/remote_client.rs
Normal file
120
crates/owlen-core/src/mcp/remote_client.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use super::protocol::{RequestId, RpcErrorResponse, RpcRequest, RpcResponse};
|
||||
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::{Error, Result};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Client that talks to the external `owlen-mcp-server` over STDIO.
|
||||
pub struct RemoteMcpClient {
|
||||
// Child process handling the server (kept alive for the duration of the client).
|
||||
#[allow(dead_code)]
|
||||
child: Arc<Mutex<Child>>, // guarded for mutable access across calls
|
||||
// Writer to server stdin.
|
||||
stdin: Arc<Mutex<tokio::process::ChildStdin>>, // async write
|
||||
// Reader for server stdout.
|
||||
stdout: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
|
||||
// Incrementing request identifier.
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl RemoteMcpClient {
|
||||
/// Spawn the MCP server binary and prepare communication channels.
|
||||
pub fn new() -> Result<Self> {
|
||||
// Locate the binary – it is built by Cargo into target/debug.
|
||||
// The test binary runs inside the crate directory, so we check a couple of relative locations.
|
||||
// Attempt to locate the server binary; if unavailable we will fall back to launching via `cargo run`.
|
||||
let _ = ();
|
||||
// Resolve absolute path based on workspace root to avoid cwd dependence.
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.map_err(Error::Io)?;
|
||||
let binary_path = workspace_root.join("target/debug/owlen-mcp-server");
|
||||
if !binary_path.exists() {
|
||||
return Err(Error::NotImplemented(format!(
|
||||
"owlen-mcp-server binary not found at {}",
|
||||
binary_path.display()
|
||||
)));
|
||||
}
|
||||
// Launch the already‑built server binary directly.
|
||||
let mut child = Command::new(&binary_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(Error::Io)?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| {
|
||||
Error::Io(std::io::Error::other(
|
||||
"Failed to capture stdin of MCP server",
|
||||
))
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
Error::Io(std::io::Error::other(
|
||||
"Failed to capture stdout of MCP server",
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
stdin: Arc::new(Mutex::new(stdin)),
|
||||
stdout: Arc::new(Mutex::new(BufReader::new(stdout))),
|
||||
next_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let id = RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed));
|
||||
let request = RpcRequest::new(id.clone(), method, Some(params));
|
||||
let req_str = serde_json::to_string(&request)? + "\n";
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
stdin.write_all(req_str.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
}
|
||||
// Read a single line response
|
||||
let mut line = String::new();
|
||||
{
|
||||
let mut stdout = self.stdout.lock().await;
|
||||
stdout.read_line(&mut line).await?;
|
||||
}
|
||||
// Try to parse successful response first
|
||||
if let Ok(resp) = serde_json::from_str::<RpcResponse>(&line) {
|
||||
if resp.id == id {
|
||||
return Ok(resp.result);
|
||||
}
|
||||
}
|
||||
// Fallback to error response
|
||||
let err_resp: RpcErrorResponse =
|
||||
serde_json::from_str(&line).map_err(Error::Serialization)?;
|
||||
Err(Error::Network(format!(
|
||||
"MCP server error {}: {}",
|
||||
err_resp.error.code, err_resp.error.message
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpClient for RemoteMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
// The file server does not expose tool descriptors; fall back to NotImplemented.
|
||||
Err(Error::NotImplemented(
|
||||
"Remote MCP client does not support list_tools".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
let result = self.send_rpc(&call.name, call.arguments.clone()).await?;
|
||||
// The remote server returns only the tool result; we fabricate metadata.
|
||||
Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: result,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user