refactor(workspace)!: move MCP crates under crates/mcp/ and update paths

This commit is contained in:
2025-10-17 00:31:35 +02:00
parent 3271697f6b
commit d4030dc598
14 changed files with 10 additions and 10 deletions

View File

@@ -0,0 +1,12 @@
[package]
name = "owlen-mcp-client"
version = "0.1.0"
edition.workspace = true
description = "Dedicated MCP client library for Owlen, exposing remote MCP server communication"
license = "AGPL-3.0"
[dependencies]
owlen-core = { path = "../../owlen-core" }
[features]
default = []

View File

@@ -0,0 +1,17 @@
//! Owlen MCP client library.
//!
//! This crate provides a thin façade over the remote MCP client implementation
//! inside `owlen-core`. It reexports the most useful types so downstream
//! crates can depend only on `owlen-mcp-client` without pulling in the entire
//! core crate internals.
pub use owlen_core::config::{McpConfigScope, ScopedMcpServer};
pub use owlen_core::mcp::remote_client::RemoteMcpClient;
pub use owlen_core::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
// Reexport the core Provider trait so that the MCP client can also be used as an LLM provider.
pub use owlen_core::Provider as McpProvider;
// Note: The `RemoteMcpClient` type provides its own `new` constructor in the core
// crate. Users can call `RemoteMcpClient::new()` directly. No additional wrapper
// is needed here.

View File

@@ -0,0 +1,22 @@
[package]
name = "owlen-mcp-code-server"
version = "0.1.0"
edition.workspace = true
description = "MCP server exposing safe code execution tools for Owlen"
license = "AGPL-3.0"
[dependencies]
owlen-core = { path = "../../owlen-core" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
bollard = "0.17"
tempfile = { workspace = true }
uuid = { workspace = true }
futures = { workspace = true }
[lib]
name = "owlen_mcp_code_server"
path = "src/lib.rs"

View File

@@ -0,0 +1,186 @@
//! MCP server exposing code execution tools with Docker sandboxing.
//!
//! This server provides:
//! - compile_project: Build projects (Rust, Node.js, Python)
//! - run_tests: Execute test suites
//! - format_code: Run code formatters
//! - lint_code: Run linters
pub mod sandbox;
pub mod tools;
use owlen_core::mcp::protocol::{
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, methods,
};
use owlen_core::tools::{Tool, ToolResult};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
use tools::{CompileProjectTool, FormatCodeTool, LintCodeTool, RunTestsTool};
/// Tool registry for the code server
#[allow(dead_code)]
struct ToolRegistry {
tools: HashMap<String, Box<dyn Tool + Send + Sync>>,
}
#[allow(dead_code)]
impl ToolRegistry {
fn new() -> Self {
let mut tools: HashMap<String, Box<dyn Tool + Send + Sync>> = HashMap::new();
tools.insert(
"compile_project".to_string(),
Box::new(CompileProjectTool::new()),
);
tools.insert("run_tests".to_string(), Box::new(RunTestsTool::new()));
tools.insert("format_code".to_string(), Box::new(FormatCodeTool::new()));
tools.insert("lint_code".to_string(), Box::new(LintCodeTool::new()));
Self { tools }
}
fn list_tools(&self) -> Vec<owlen_core::mcp::McpToolDescriptor> {
self.tools
.values()
.map(|tool| owlen_core::mcp::McpToolDescriptor {
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: tool.schema(),
requires_network: tool.requires_network(),
requires_filesystem: tool.requires_filesystem(),
})
.collect()
}
async fn execute(&self, name: &str, args: Value) -> Result<ToolResult, String> {
self.tools
.get(name)
.ok_or_else(|| format!("Tool not found: {}", name))?
.execute(args)
.await
.map_err(|e| e.to_string())
}
}
#[allow(dead_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
let registry = Arc::new(ToolRegistry::new());
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let resp = handle_request(req.clone(), registry.clone()).await;
match resp {
Ok(r) => {
let s = serde_json::to_string(&r)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(e) => {
let err = RpcErrorResponse::new(req.id.clone(), e);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Error reading stdin: {}", e);
break;
}
}
}
Ok(())
}
#[allow(dead_code)]
async fn handle_request(
req: RpcRequest,
registry: Arc<ToolRegistry>,
) -> Result<RpcResponse, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params: InitializeParams =
serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !params.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
params.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-code-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(false),
},
};
let payload = serde_json::to_value(result).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize initialize result: {}", e))
})?;
Ok(RpcResponse::new(req.id, payload))
}
methods::TOOLS_LIST => {
let tools = registry.list_tools();
Ok(RpcResponse::new(req.id, json!(tools)))
}
methods::TOOLS_CALL => {
let call = serde_json::from_value::<owlen_core::mcp::McpToolCall>(
req.params.unwrap_or_else(|| json!({})),
)
.map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?;
let result: ToolResult = registry
.execute(&call.name, call.arguments)
.await
.map_err(|e| RpcError::internal_error(format!("Tool execution failed: {}", e)))?;
let resp = owlen_core::mcp::McpToolResponse {
name: call.name,
success: result.success,
output: result.output,
metadata: result.metadata,
duration_ms: result.duration.as_millis() as u128,
};
let payload = serde_json::to_value(resp).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize tool response: {}", e))
})?;
Ok(RpcResponse::new(req.id, payload))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}

View File

@@ -0,0 +1,250 @@
//! Docker-based sandboxing for secure code execution
use anyhow::{Context, Result};
use bollard::Docker;
use bollard::container::{
Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
WaitContainerOptions,
};
use bollard::models::{HostConfig, Mount, MountTypeEnum};
use std::collections::HashMap;
use std::path::Path;
/// Result of executing code in a sandbox
#[derive(Debug, Clone)]
pub struct ExecutionResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i64,
pub timed_out: bool,
}
/// Docker-based sandbox executor
pub struct Sandbox {
docker: Docker,
memory_limit: i64,
cpu_quota: i64,
timeout_secs: u64,
}
impl Sandbox {
/// Create a new sandbox with default resource limits
pub fn new() -> Result<Self> {
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
Ok(Self {
docker,
memory_limit: 512 * 1024 * 1024, // 512MB
cpu_quota: 50000, // 50% of one core
timeout_secs: 30,
})
}
/// Execute a command in a sandboxed container
pub async fn execute(
&self,
image: &str,
cmd: &[&str],
workspace: Option<&Path>,
env: HashMap<String, String>,
) -> Result<ExecutionResult> {
let container_name = format!("owlen-sandbox-{}", uuid::Uuid::new_v4());
// Prepare volume mount if workspace provided
let mounts = if let Some(ws) = workspace {
vec![Mount {
target: Some("/workspace".to_string()),
source: Some(ws.to_string_lossy().to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
}]
} else {
vec![]
};
// Create container config
let host_config = HostConfig {
memory: Some(self.memory_limit),
cpu_quota: Some(self.cpu_quota),
network_mode: Some("none".to_string()), // No network access
mounts: Some(mounts),
auto_remove: Some(true),
..Default::default()
};
let config = Config {
image: Some(image.to_string()),
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
working_dir: Some("/workspace".to_string()),
env: Some(env.iter().map(|(k, v)| format!("{}={}", k, v)).collect()),
host_config: Some(host_config),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(false),
..Default::default()
};
// Create container
let container = self
.docker
.create_container(
Some(CreateContainerOptions {
name: container_name.clone(),
..Default::default()
}),
config,
)
.await
.context("Failed to create container")?;
// Start container
self.docker
.start_container(&container.id, None::<StartContainerOptions<String>>)
.await
.context("Failed to start container")?;
// Wait for container with timeout
let wait_result =
tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), async {
let mut wait_stream = self
.docker
.wait_container(&container.id, None::<WaitContainerOptions<String>>);
use futures::StreamExt;
if let Some(result) = wait_stream.next().await {
result
} else {
Err(bollard::errors::Error::IOError {
err: std::io::Error::other("Container wait stream ended unexpectedly"),
})
}
})
.await;
let (exit_code, timed_out) = match wait_result {
Ok(Ok(result)) => (result.status_code, false),
Ok(Err(e)) => {
eprintln!("Container wait error: {}", e);
(1, false)
}
Err(_) => {
// Timeout - kill the container
let _ = self
.docker
.kill_container(
&container.id,
None::<bollard::container::KillContainerOptions<String>>,
)
.await;
(124, true)
}
};
// Get logs
let logs = self.docker.logs(
&container.id,
Some(bollard::container::LogsOptions::<String> {
stdout: true,
stderr: true,
..Default::default()
}),
);
use futures::StreamExt;
let mut stdout = String::new();
let mut stderr = String::new();
let log_result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
let mut logs = logs;
while let Some(log) = logs.next().await {
match log {
Ok(bollard::container::LogOutput::StdOut { message }) => {
stdout.push_str(&String::from_utf8_lossy(&message));
}
Ok(bollard::container::LogOutput::StdErr { message }) => {
stderr.push_str(&String::from_utf8_lossy(&message));
}
_ => {}
}
}
})
.await;
if log_result.is_err() {
eprintln!("Timeout reading container logs");
}
// Remove container (auto_remove should handle this, but be explicit)
let _ = self
.docker
.remove_container(
&container.id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
Ok(ExecutionResult {
stdout,
stderr,
exit_code,
timed_out,
})
}
/// Execute in a Rust environment
pub async fn execute_rust(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
self.execute("rust:1.75-slim", cmd, Some(workspace), HashMap::new())
.await
}
/// Execute in a Python environment
pub async fn execute_python(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
self.execute("python:3.11-slim", cmd, Some(workspace), HashMap::new())
.await
}
/// Execute in a Node.js environment
pub async fn execute_node(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
self.execute("node:20-slim", cmd, Some(workspace), HashMap::new())
.await
}
}
impl Default for Sandbox {
fn default() -> Self {
Self::new().expect("Failed to create default sandbox")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
#[ignore] // Requires Docker daemon
async fn test_sandbox_rust_compile() {
let sandbox = Sandbox::new().unwrap();
let temp_dir = TempDir::new().unwrap();
// Create a simple Rust project
std::fs::write(
temp_dir.path().join("main.rs"),
"fn main() { println!(\"Hello from sandbox!\"); }",
)
.unwrap();
let result = sandbox
.execute_rust(temp_dir.path(), &["rustc", "main.rs"])
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
}
}

View File

@@ -0,0 +1,417 @@
//! Code execution tools using Docker sandboxing
use crate::sandbox::Sandbox;
use async_trait::async_trait;
use owlen_core::Result;
use owlen_core::tools::{Tool, ToolResult};
use serde_json::{Value, json};
use std::path::PathBuf;
/// Tool for compiling projects (Rust, Node.js, Python)
pub struct CompileProjectTool {
sandbox: Sandbox,
}
impl Default for CompileProjectTool {
fn default() -> Self {
Self::new()
}
}
impl CompileProjectTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for CompileProjectTool {
fn name(&self) -> &'static str {
"compile_project"
}
fn description(&self) -> &'static str {
"Compile a project (Rust, Node.js, Python). Detects project type automatically."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"project_type": {
"type": "string",
"enum": ["rust", "node", "python"],
"description": "Project type (auto-detected if not specified)"
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
// Detect project type
let project_type = if let Some(pt) = args.get("project_type").and_then(|v| v.as_str()) {
pt.to_string()
} else if path.join("Cargo.toml").exists() {
"rust".to_string()
} else if path.join("package.json").exists() {
"node".to_string()
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
"python".to_string()
} else {
return Ok(ToolResult::error("Could not detect project type"));
};
// Execute compilation
let result = match project_type.as_str() {
"rust" => self.sandbox.execute_rust(&path, &["cargo", "build"]).await,
"node" => {
self.sandbox
.execute_node(&path, &["npm", "run", "build"])
.await
}
"python" => {
// Python typically doesn't need compilation, but we can check syntax
self.sandbox
.execute_python(&path, &["python", "-m", "compileall", "."])
.await
}
_ => return Ok(ToolResult::error("Unsupported project type")),
};
match result {
Ok(exec_result) => {
if exec_result.timed_out {
Ok(ToolResult::error("Compilation timed out"))
} else if exec_result.exit_code == 0 {
Ok(ToolResult::success(json!({
"success": true,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"project_type": project_type
})))
} else {
Ok(ToolResult::success(json!({
"success": false,
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"project_type": project_type
})))
}
}
Err(e) => Ok(ToolResult::error(&format!("Compilation failed: {}", e))),
}
}
}
/// Tool for running test suites
pub struct RunTestsTool {
sandbox: Sandbox,
}
impl Default for RunTestsTool {
fn default() -> Self {
Self::new()
}
}
impl RunTestsTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for RunTestsTool {
fn name(&self) -> &'static str {
"run_tests"
}
fn description(&self) -> &'static str {
"Run tests for a project (Rust, Node.js, Python)"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"test_filter": {
"type": "string",
"description": "Optional test filter/pattern"
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
let test_filter = args.get("test_filter").and_then(|v| v.as_str());
// Detect project type and run tests
let result = if path.join("Cargo.toml").exists() {
let cmd = if let Some(filter) = test_filter {
vec!["cargo", "test", filter]
} else {
vec!["cargo", "test"]
};
self.sandbox.execute_rust(&path, &cmd).await
} else if path.join("package.json").exists() {
self.sandbox.execute_node(&path, &["npm", "test"]).await
} else if path.join("pytest.ini").exists()
|| path.join("setup.py").exists()
|| path.join("pyproject.toml").exists()
{
let cmd = if let Some(filter) = test_filter {
vec!["pytest", "-k", filter]
} else {
vec!["pytest"]
};
self.sandbox.execute_python(&path, &cmd).await
} else {
return Ok(ToolResult::error("Could not detect test framework"));
};
match result {
Ok(exec_result) => Ok(ToolResult::success(json!({
"success": exec_result.exit_code == 0 && !exec_result.timed_out,
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"timed_out": exec_result.timed_out
}))),
Err(e) => Ok(ToolResult::error(&format!("Tests failed to run: {}", e))),
}
}
}
/// Tool for formatting code
pub struct FormatCodeTool {
sandbox: Sandbox,
}
impl Default for FormatCodeTool {
fn default() -> Self {
Self::new()
}
}
impl FormatCodeTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for FormatCodeTool {
fn name(&self) -> &'static str {
"format_code"
}
fn description(&self) -> &'static str {
"Format code using project-appropriate formatter (rustfmt, prettier, black)"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"check_only": {
"type": "boolean",
"description": "Only check formatting without modifying files",
"default": false
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
let check_only = args
.get("check_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Detect project type and run formatter
let result = if path.join("Cargo.toml").exists() {
let cmd = if check_only {
vec!["cargo", "fmt", "--", "--check"]
} else {
vec!["cargo", "fmt"]
};
self.sandbox.execute_rust(&path, &cmd).await
} else if path.join("package.json").exists() {
let cmd = if check_only {
vec!["npx", "prettier", "--check", "."]
} else {
vec!["npx", "prettier", "--write", "."]
};
self.sandbox.execute_node(&path, &cmd).await
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
let cmd = if check_only {
vec!["black", "--check", "."]
} else {
vec!["black", "."]
};
self.sandbox.execute_python(&path, &cmd).await
} else {
return Ok(ToolResult::error("Could not detect project type"));
};
match result {
Ok(exec_result) => Ok(ToolResult::success(json!({
"success": exec_result.exit_code == 0,
"formatted": !check_only && exec_result.exit_code == 0,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr
}))),
Err(e) => Ok(ToolResult::error(&format!("Formatting failed: {}", e))),
}
}
}
/// Tool for linting code
pub struct LintCodeTool {
sandbox: Sandbox,
}
impl Default for LintCodeTool {
fn default() -> Self {
Self::new()
}
}
impl LintCodeTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for LintCodeTool {
fn name(&self) -> &'static str {
"lint_code"
}
fn description(&self) -> &'static str {
"Lint code using project-appropriate linter (clippy, eslint, pylint)"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"fix": {
"type": "boolean",
"description": "Automatically fix issues if possible",
"default": false
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
let fix = args.get("fix").and_then(|v| v.as_bool()).unwrap_or(false);
// Detect project type and run linter
let result = if path.join("Cargo.toml").exists() {
let cmd = if fix {
vec!["cargo", "clippy", "--fix", "--allow-dirty"]
} else {
vec!["cargo", "clippy"]
};
self.sandbox.execute_rust(&path, &cmd).await
} else if path.join("package.json").exists() {
let cmd = if fix {
vec!["npx", "eslint", ".", "--fix"]
} else {
vec!["npx", "eslint", "."]
};
self.sandbox.execute_node(&path, &cmd).await
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
// pylint doesn't have auto-fix
self.sandbox.execute_python(&path, &["pylint", "."]).await
} else {
return Ok(ToolResult::error("Could not detect project type"));
};
match result {
Ok(exec_result) => {
let issues_found = exec_result.exit_code != 0;
Ok(ToolResult::success(json!({
"success": true,
"issues_found": issues_found,
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr
})))
}
Err(e) => Ok(ToolResult::error(&format!("Linting failed: {}", e))),
}
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "owlen-mcp-llm-server"
version = "0.1.0"
edition.workspace = true
[dependencies]
owlen-core = { path = "../../owlen-core" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tokio-stream = { workspace = true }
[[bin]]
name = "owlen-mcp-llm-server"
path = "src/main.rs"

View File

@@ -0,0 +1,597 @@
#![allow(
unused_imports,
unused_variables,
dead_code,
clippy::unnecessary_cast,
clippy::manual_flatten,
clippy::empty_line_after_outer_attr
)]
use owlen_core::Provider;
use owlen_core::ProviderConfig;
use owlen_core::config::{Config as OwlenConfig, ensure_provider_config};
use owlen_core::mcp::protocol::{
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo,
methods,
};
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
use owlen_core::providers::OllamaProvider;
use owlen_core::types::{ChatParameters, ChatRequest, Message};
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
use tokio_stream::StreamExt;
// Suppress warnings are handled by the crate-level attribute at the top.
/// Arguments for the generate_text tool
#[derive(Debug, Deserialize)]
struct GenerateTextArgs {
messages: Vec<Message>,
temperature: Option<f32>,
max_tokens: Option<u32>,
model: String,
stream: bool,
}
/// Simple tool descriptor for generate_text
fn generate_text_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "generate_text".to_string(),
description: "Generate text using Ollama LLM. Each message must have 'role' (user/assistant/system) and 'content' (string) fields.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": ["user", "assistant", "system"],
"description": "The role of the message sender"
},
"content": {
"type": "string",
"description": "The message content"
}
},
"required": ["role", "content"]
},
"description": "Array of message objects with role and content"
},
"temperature": {"type": ["number", "null"], "description": "Sampling temperature (0.0-2.0)"},
"max_tokens": {"type": ["integer", "null"], "description": "Maximum tokens to generate"},
"model": {"type": "string", "description": "Model name (e.g., llama3.2:latest)"},
"stream": {"type": "boolean", "description": "Whether to stream the response"}
},
"required": ["messages", "model", "stream"]
}),
requires_network: true,
requires_filesystem: vec![],
}
}
/// Tool descriptor for resources/get (read file)
fn resources_get_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "resources/get".to_string(),
description: "Read and return the TEXT CONTENTS of a single FILE. Use this to read the contents of code files, config files, or text documents. Do NOT use for directories.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the FILE (not directory) to read"}
},
"required": ["path"]
}),
requires_network: false,
requires_filesystem: vec!["read".to_string()],
}
}
/// Tool descriptor for resources/list (list directory)
fn resources_list_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "resources/list".to_string(),
description: "List the NAMES of all files and directories in a directory. Use this to see what files exist in a folder, or to list directory contents. Returns an array of file/directory names.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the DIRECTORY to list (use '.' for current directory)"}
}
}),
requires_network: false,
requires_filesystem: vec!["read".to_string()],
}
}
fn provider_from_config() -> Result<Arc<dyn Provider>, RpcError> {
let mut config = OwlenConfig::load(None).unwrap_or_default();
let requested_name =
env::var("OWLEN_PROVIDER").unwrap_or_else(|_| config.general.default_provider.clone());
let provider_key = canonical_provider_name(&requested_name);
if config.provider(&provider_key).is_none() {
ensure_provider_config(&mut config, &provider_key);
}
let provider_cfg: ProviderConfig =
config.provider(&provider_key).cloned().ok_or_else(|| {
RpcError::internal_error(format!(
"Provider '{provider_key}' not found in configuration"
))
})?;
match provider_cfg.provider_type.as_str() {
"ollama" | "ollama_cloud" => {
let provider = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
.map_err(|e| {
RpcError::internal_error(format!(
"Failed to init Ollama provider from config: {e}"
))
})?;
Ok(Arc::new(provider) as Arc<dyn Provider>)
}
other => Err(RpcError::internal_error(format!(
"Unsupported provider type '{other}' for MCP LLM server"
))),
}
}
fn create_provider() -> Result<Arc<dyn Provider>, RpcError> {
if let Ok(url) = env::var("OLLAMA_URL") {
let provider = OllamaProvider::new(&url).map_err(|e| {
RpcError::internal_error(format!("Failed to init Ollama provider: {e}"))
})?;
return Ok(Arc::new(provider) as Arc<dyn Provider>);
}
provider_from_config()
}
fn canonical_provider_name(name: &str) -> String {
let normalized = name.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"" => "ollama_local".to_string(),
"ollama" | "ollama_local" => "ollama_local".to_string(),
"ollama_cloud" => "ollama_cloud".to_string(),
other => other.to_string(),
}
}
async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> {
let provider = create_provider()?;
let parameters = ChatParameters {
temperature: args.temperature,
max_tokens: args.max_tokens.map(|v| v as u32),
stream: args.stream,
extra: HashMap::new(),
};
let request = ChatRequest {
model: args.model,
messages: args.messages,
parameters,
tools: None,
};
// Use streaming API and collect output
let mut stream = provider
.stream_prompt(request)
.await
.map_err(|e| RpcError::internal_error(format!("Chat request failed: {}", e)))?;
let mut content = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
content.push_str(&resp.message.content);
if resp.is_final {
break;
}
}
Err(e) => {
return Err(RpcError::internal_error(format!("Stream error: {}", e)));
}
}
}
Ok(content)
}
async fn handle_request(req: &RpcRequest) -> Result<Value, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?;
let init: InitializeParams = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !init.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
init.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-llm-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(true),
},
};
serde_json::to_value(result).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize init result: {}", e))
})
}
methods::TOOLS_LIST => {
let tools = vec![
generate_text_descriptor(),
resources_get_descriptor(),
resources_list_descriptor(),
];
Ok(json!(tools))
}
// New method to list available Ollama models via the provider.
methods::MODELS_LIST => {
let provider = create_provider()?;
let models = provider
.list_models()
.await
.map_err(|e| RpcError::internal_error(format!("Failed to list models: {}", e)))?;
serde_json::to_value(models).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize model list: {}", e))
})
}
methods::TOOLS_CALL => {
// For streaming we will send incremental notifications directly from here.
// The caller (main loop) will handle writing the final response.
Err(RpcError::internal_error(
"TOOLS_CALL should be handled in main loop for streaming",
))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?; // not used but kept for parity
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let id = req.id.clone();
// Streaming tool calls (generate_text) are handled specially to emit incremental notifications.
if req.method == methods::TOOLS_CALL {
// Parse the tool call
let params = match &req.params {
Some(p) => p,
None => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params("Missing params for tool call"),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let call: McpToolCall = match serde_json::from_value(params.clone()) {
Ok(c) => c,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params(format!("Invalid tool call: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Dispatch based on the requested tool name.
// Handle resources tools manually.
if call.name.starts_with("resources/get") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
match std::fs::read_to_string(path) {
Ok(content) => {
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(content),
metadata: HashMap::new(),
duration_ms: 0,
};
let payload = match serde_json::to_value(&response) {
Ok(value) => value,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!(
"Failed to serialize resource response: {}",
e
)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let final_resp = RpcResponse::new(id.clone(), payload);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Failed to read file: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
}
}
if call.name.starts_with("resources/list") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
match std::fs::read_dir(path) {
Ok(entries) => {
let mut names = Vec::new();
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(names),
metadata: HashMap::new(),
duration_ms: 0,
};
let payload = match serde_json::to_value(&response) {
Ok(value) => value,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!(
"Failed to serialize directory listing: {}",
e
)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let final_resp = RpcResponse::new(id.clone(), payload);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Failed to list dir: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
}
}
// Expect generate_text tool for the remaining path.
if call.name != "generate_text" {
let err_resp =
RpcErrorResponse::new(id.clone(), RpcError::tool_not_found(&call.name));
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
let args: GenerateTextArgs =
match serde_json::from_value(call.arguments.clone()) {
Ok(a) => a,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params(format!("Invalid arguments: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Initialize provider and start streaming
let provider = match create_provider() {
Ok(p) => p,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!(
"Failed to initialize provider: {:?}",
e
)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let parameters = ChatParameters {
temperature: args.temperature,
max_tokens: args.max_tokens.map(|v| v as u32),
stream: true,
extra: HashMap::new(),
};
let request = ChatRequest {
model: args.model,
messages: args.messages,
parameters,
tools: None,
};
let mut stream = match provider.stream_prompt(request).await {
Ok(s) => s,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Chat request failed: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Accumulate full content while sending incremental progress notifications
let mut final_content = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
// Append chunk to the final content buffer
final_content.push_str(&resp.message.content);
// Emit a progress notification for the UI
let notif = RpcNotification::new(
"tools/call/progress",
Some(json!({ "content": resp.message.content })),
);
let s = serde_json::to_string(&notif)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
if resp.is_final {
break;
}
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Stream error: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
break;
}
}
}
// After streaming, send the final tool response containing the full content
let final_output = final_content.clone();
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(final_output),
metadata: HashMap::new(),
duration_ms: 0,
};
let payload = match serde_json::to_value(&response) {
Ok(value) => value,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!(
"Failed to serialize final streaming response: {}",
e
)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let final_resp = RpcResponse::new(id.clone(), payload);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
// Nonstreaming requests are handled by the generic handler
match handle_request(&req).await {
Ok(res) => {
let resp = RpcResponse::new(id, res);
let s = serde_json::to_string(&resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(err) => {
let err_resp = RpcErrorResponse::new(id, err);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Read error: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -0,0 +1,21 @@
[package]
name = "owlen-mcp-prompt-server"
version = "0.1.0"
edition.workspace = true
description = "MCP server that renders prompt templates (YAML) for Owlen"
license = "AGPL-3.0"
[dependencies]
owlen-core = { path = "../../owlen-core" }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
handlebars = { workspace = true }
dirs = { workspace = true }
futures = { workspace = true }
[lib]
name = "owlen_mcp_prompt_server"
path = "src/lib.rs"

View File

@@ -0,0 +1,415 @@
//! MCP server for rendering prompt templates with YAML storage and Handlebars rendering.
//!
//! Templates are stored in `~/.config/owlen/prompts/` as YAML files.
//! Provides full Handlebars templating support for dynamic prompt generation.
use anyhow::{Context, Result};
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use owlen_core::mcp::protocol::{
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, methods,
};
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
/// Prompt template definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptTemplate {
/// Template name
pub name: String,
/// Template version
pub version: String,
/// Optional mode restriction
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// Handlebars template content
pub template: String,
/// Template description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// Prompt server managing templates
pub struct PromptServer {
templates: Arc<RwLock<HashMap<String, PromptTemplate>>>,
handlebars: Handlebars<'static>,
templates_dir: PathBuf,
}
impl PromptServer {
/// Create a new prompt server
pub fn new() -> Result<Self> {
let templates_dir = Self::get_templates_dir()?;
// Create templates directory if it doesn't exist
if !templates_dir.exists() {
fs::create_dir_all(&templates_dir)?;
Self::create_default_templates(&templates_dir)?;
}
let mut server = Self {
templates: Arc::new(RwLock::new(HashMap::new())),
handlebars: Handlebars::new(),
templates_dir,
};
// Load all templates
server.load_templates()?;
Ok(server)
}
/// Get the templates directory path
fn get_templates_dir() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("owlen").join("prompts"))
}
/// Create default template examples
fn create_default_templates(dir: &Path) -> Result<()> {
let chat_mode_system = PromptTemplate {
name: "chat_mode_system".to_string(),
version: "1.0".to_string(),
mode: Some("chat".to_string()),
description: Some("System prompt for chat mode".to_string()),
template: r#"You are Owlen, a helpful AI assistant. You have access to these tools:
{{#each tools}}
- {{name}}: {{description}}
{{/each}}
Use the ReAct pattern:
THOUGHT: Your reasoning
ACTION: tool_name
ACTION_INPUT: {"param": "value"}
When you have enough information:
FINAL_ANSWER: Your response"#
.to_string(),
};
let code_mode_system = PromptTemplate {
name: "code_mode_system".to_string(),
version: "1.0".to_string(),
mode: Some("code".to_string()),
description: Some("System prompt for code mode".to_string()),
template: r#"You are Owlen in code mode, with full development capabilities. You have access to:
{{#each tools}}
- {{name}}: {{description}}
{{/each}}
Use the ReAct pattern to solve coding tasks:
THOUGHT: Analyze what needs to be done
ACTION: tool_name (compile_project, run_tests, format_code, lint_code, etc.)
ACTION_INPUT: {"param": "value"}
Continue iterating until the task is complete, then provide:
FINAL_ANSWER: Summary of what was done"#
.to_string(),
};
// Save templates
let chat_path = dir.join("chat_mode_system.yaml");
let code_path = dir.join("code_mode_system.yaml");
fs::write(chat_path, serde_yaml::to_string(&chat_mode_system)?)?;
fs::write(code_path, serde_yaml::to_string(&code_mode_system)?)?;
Ok(())
}
/// Load all templates from the templates directory
fn load_templates(&mut self) -> Result<()> {
let entries = fs::read_dir(&self.templates_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml")
|| path.extension().and_then(|s| s.to_str()) == Some("yml")
{
match self.load_template(&path) {
Ok(template) => {
// Register with Handlebars
if let Err(e) = self
.handlebars
.register_template_string(&template.name, &template.template)
{
eprintln!(
"Warning: Failed to register template {}: {}",
template.name, e
);
} else {
let mut templates = self.templates.blocking_write();
templates.insert(template.name.clone(), template);
}
}
Err(e) => {
eprintln!("Warning: Failed to load template {:?}: {}", path, e);
}
}
}
}
Ok(())
}
/// Load a single template from file
fn load_template(&self, path: &Path) -> Result<PromptTemplate> {
let content = fs::read_to_string(path)?;
let template: PromptTemplate = serde_yaml::from_str(&content)?;
Ok(template)
}
/// Get a template by name
pub async fn get_template(&self, name: &str) -> Option<PromptTemplate> {
let templates = self.templates.read().await;
templates.get(name).cloned()
}
/// List all available templates
pub async fn list_templates(&self) -> Vec<String> {
let templates = self.templates.read().await;
templates.keys().cloned().collect()
}
/// Render a template with given variables
pub fn render_template(&self, name: &str, vars: &Value) -> Result<String> {
self.handlebars
.render(name, vars)
.context("Failed to render template")
}
/// Reload all templates from disk
pub async fn reload_templates(&mut self) -> Result<()> {
{
let mut templates = self.templates.write().await;
templates.clear();
}
self.handlebars = Handlebars::new();
self.load_templates()
}
}
#[allow(dead_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
let server = Arc::new(tokio::sync::Mutex::new(PromptServer::new()?));
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let resp = handle_request(req.clone(), server.clone()).await;
match resp {
Ok(r) => {
let s = serde_json::to_string(&r)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(e) => {
let err = RpcErrorResponse::new(req.id.clone(), e);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Error reading stdin: {}", e);
break;
}
}
}
Ok(())
}
#[allow(dead_code)]
async fn handle_request(
req: RpcRequest,
server: Arc<tokio::sync::Mutex<PromptServer>>,
) -> Result<RpcResponse, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params: InitializeParams =
serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !params.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
params.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-prompt-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(false),
},
};
let payload = serde_json::to_value(result).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize initialize result: {}", e))
})?;
Ok(RpcResponse::new(req.id, payload))
}
methods::TOOLS_LIST => {
let tools = vec![
McpToolDescriptor {
name: "get_prompt".to_string(),
description: "Retrieve a prompt template by name".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Template name"}
},
"required": ["name"]
}),
requires_network: false,
requires_filesystem: vec![],
},
McpToolDescriptor {
name: "render_prompt".to_string(),
description: "Render a prompt template with Handlebars variables".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Template name"},
"vars": {"type": "object", "description": "Variables for Handlebars rendering"}
},
"required": ["name"]
}),
requires_network: false,
requires_filesystem: vec![],
},
McpToolDescriptor {
name: "list_prompts".to_string(),
description: "List all available prompt templates".to_string(),
input_schema: json!({"type": "object", "properties": {}}),
requires_network: false,
requires_filesystem: vec![],
},
McpToolDescriptor {
name: "reload_prompts".to_string(),
description: "Reload all prompts from disk".to_string(),
input_schema: json!({"type": "object", "properties": {}}),
requires_network: false,
requires_filesystem: vec![],
},
];
Ok(RpcResponse::new(req.id, json!(tools)))
}
methods::TOOLS_CALL => {
let call: McpToolCall = serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
.map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?;
let result = match call.name.as_str() {
"get_prompt" => {
let name = call
.arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?;
let srv = server.lock().await;
match srv.get_template(name).await {
Some(template) => match serde_json::to_value(template) {
Ok(serialized) => {
json!({"success": true, "template": serialized})
}
Err(e) => {
return Err(RpcError::internal_error(format!(
"Failed to serialize template '{}': {}",
name, e
)));
}
},
None => json!({"success": false, "error": "Template not found"}),
}
}
"render_prompt" => {
let name = call
.arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?;
let default_vars = json!({});
let vars = call.arguments.get("vars").unwrap_or(&default_vars);
let srv = server.lock().await;
match srv.render_template(name, vars) {
Ok(rendered) => json!({"success": true, "rendered": rendered}),
Err(e) => json!({"success": false, "error": e.to_string()}),
}
}
"list_prompts" => {
let srv = server.lock().await;
let templates = srv.list_templates().await;
json!({"success": true, "templates": templates})
}
"reload_prompts" => {
let mut srv = server.lock().await;
match srv.reload_templates().await {
Ok(_) => json!({"success": true, "message": "Prompts reloaded"}),
Err(e) => json!({"success": false, "error": e.to_string()}),
}
}
_ => return Err(RpcError::method_not_found(&call.name)),
};
let resp = McpToolResponse {
name: call.name,
success: result
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false),
output: result,
metadata: HashMap::new(),
duration_ms: 0,
};
let payload = serde_json::to_value(resp).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize tool response: {}", e))
})?;
Ok(RpcResponse::new(req.id, payload))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}

View File

@@ -0,0 +1,3 @@
prompt: |
Hello {{name}}!
Your role is: {{role}}.

View File

@@ -0,0 +1,12 @@
[package]
name = "owlen-mcp-server"
version = "0.1.0"
edition.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
path-clean = "1.0"
owlen-core = { path = "../../owlen-core" }

View File

@@ -0,0 +1,246 @@
use owlen_core::mcp::protocol::{
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, is_compatible,
};
use path_clean::PathClean;
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
#[derive(Deserialize)]
struct WriteArgs {
path: String,
content: String,
}
async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Value, RpcError> {
match req.method.as_str() {
"initialize" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?;
let init_params: InitializeParams =
serde_json::from_value(params.clone()).map_err(|e| {
RpcError::invalid_params(format!("Invalid initialize params: {}", e))
})?;
// Check protocol version compatibility
if !is_compatible(&init_params.protocol_version, PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
init_params.protocol_version, PROTOCOL_VERSION
),
));
}
// Build initialization result
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(false),
supports_resources: Some(true), // Supports read, write, delete
supports_streaming: Some(false),
},
};
Ok(serde_json::to_value(result).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize result: {}", e))
})?)
}
"resources/list" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: FileArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_list(&args.path, root).await
}
"resources/get" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: FileArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_get(&args.path, root).await
}
"resources/write" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: WriteArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_write(&args.path, &args.content, root).await
}
"resources/delete" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: FileArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_delete(&args.path, root).await
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, RpcError> {
let path = Path::new(path);
let path = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| RpcError::invalid_params("Invalid path"))?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(RpcError::path_traversal());
}
Ok(full_path)
}
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
let entries = fs::read_dir(full_path).map_err(|e| {
RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
format!("Failed to read directory: {}", e),
)
})?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
RpcError::internal_error(format!("Failed to read directory entry: {}", e))
})?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(serde_json::json!(result))
}
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
let content = fs::read_to_string(full_path).map_err(|e| {
RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
format!("Failed to read file: {}", e),
)
})?;
Ok(serde_json::json!(content))
}
async fn resources_write(
path: &str,
content: &str,
root: &Path,
) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
// Ensure parent directory exists
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
RpcError::internal_error(format!("Failed to create parent directories: {}", e))
})?;
}
std::fs::write(full_path, content)
.map_err(|e| RpcError::internal_error(format!("Failed to write file: {}", e)))?;
Ok(serde_json::json!(null))
}
async fn resources_delete(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
if full_path.is_file() {
std::fs::remove_file(full_path)
.map_err(|e| RpcError::internal_error(format!("Failed to delete file: {}", e)))?;
Ok(serde_json::json!(null))
} else {
Err(RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
"Path does not refer to a file",
))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?;
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => {
// EOF
break;
}
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(req) => req,
Err(e) => {
let err_resp = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let request_id = req.id.clone();
match handle_request(&req, &root).await {
Ok(result) => {
let resp = RpcResponse::new(request_id, result);
let resp_str = serde_json::to_string(&resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(error) => {
let err_resp = RpcErrorResponse::new(request_id, error);
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
// Handle read error
eprintln!("Error reading from stdin: {}", e);
break;
}
}
}
Ok(())
}