refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
@@ -0,0 +1,507 @@
//! MCP server connection management: spawning/talking to stdio child
//! processes and HTTP endpoints, and adapting their advertised tools to
//! the crate's `Tool` trait.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::sync::{Arc, Mutex, OnceLock};
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
/// Global cache for `&'static str` names/descriptions of MCP tools, so we
/// never need `Box::leak`. Entries are never removed (small, bounded by the
/// number of MCP tools ever registered in a session).
fn mcp_static_str(s: &str) -> &'static str {
static CACHE: OnceLock<Mutex<Vec<&'static str>>> = OnceLock::new();
let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() {
Ok(c) => c,
Err(poisoned) => {
tracing::warn!("[mcp] static string cache mutex poisoned, recovering");
poisoned.into_inner()
}
};
if let Some(&existing) = cache.iter().find(|e| **e == s) {
return existing;
}
let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
cache.push(leaked);
leaked
}
/// How an MCP server is reached: a spawned child process talking
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum McpTransport {
Stdio { command: String, args: Vec<String> },
StreamableHttp { url: String },
}
/// A single tool advertised by an MCP server, as returned by `tools/list`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolInfo {
pub name: String,
pub description: String,
pub input_schema: Value,
}
/// A connected MCP server: its transport, advertised tools, and (for stdio)
/// a live handle to the child process.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub name: String,
pub transport: McpTransport,
pub tools: Vec<McpToolInfo>,
/// Held child-process handle so subsequent tool calls reuse the same
/// connection instead of spawning a new child each time. Not serialized
/// because the child only lives in this process.
#[serde(skip)]
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
}
/// Live handle to an MCP server child process communicating over stdio
/// via newline-delimited JSON-RPC 2.0.
#[derive(Debug)]
pub struct StdioChild {
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
}
impl StdioChild {
/// Send a JSON-RPC request to the child and block for its matching response.
///
/// Flow: assign the next request id → write request + newline to stdin →
/// loop reading lines from stdout until one has a matching `id` or the
/// timeout elapses → return its `result` (or error out on an `error` field).
///
/// Why: the child may interleave unrelated/malformed lines, so blank
/// lines are skipped and non-matching ids are ignored rather than
/// treated as a protocol violation.
///
/// Return: the `result` value of the matching response, or `Err` on
/// timeout, EOF, JSON-RPC error, or I/O failure.
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
self.next_id += 1;
let id = self.next_id;
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
let mut line = serde_json::to_string(&req)?;
line.push('\n');
self.stdin.write_all(line.as_bytes())?;
self.stdin.flush()?;
let mut response_line = String::new();
let deadline =
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
loop {
if std::time::Instant::now() > deadline {
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
}
// Read one byte at a time up to MAX_LINE_LENGTH to prevent
// OOM from a malicious server (CWE-400). BufReader already
// buffers reads, so byte-by-byte over a buffered reader is
// cheap (hits the in-memory buffer).
response_line.clear();
let mut line_truncated = false;
loop {
let byte = match self.stdout.fill_buf() {
Ok([]) => {
// EOF without newline
anyhow::bail!("MCP stdio child process closed unexpectedly");
}
Ok(buf) => {
let b = buf[0];
self.stdout.consume(1);
b
}
Err(e) => anyhow::bail!("MCP stdio read error: {e}"),
};
if byte == b'\n' {
break;
}
if response_line.len() >= MAX_LINE_LENGTH {
line_truncated = true;
// Consume rest of line to keep stream in sync
loop {
let buf = self
.stdout
.fill_buf()
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
if buf.is_empty() {
anyhow::bail!("MCP stdio child closed mid-line");
}
if buf[0] == b'\n' {
self.stdout.consume(1);
break;
}
self.stdout.consume(1);
}
break;
}
response_line.push(byte as char);
}
if line_truncated {
anyhow::bail!("MCP response line exceeded {MAX_LINE_LENGTH} byte limit");
}
let trimmed = response_line.trim();
if trimmed.is_empty() {
continue;
}
let resp: Value = serde_json::from_str(trimmed)
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {e}"))?;
if resp.get("id") == Some(&json!(id)) {
if let Some(err) = resp.get("error") {
anyhow::bail!("MCP error: {err}");
}
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
Value::Null
}));
}
}
} // close fn call
} // close impl StdioChild
pub(crate) fn spawn_stdio_child(
command: &str,
extra_args: &[String],
) -> anyhow::Result<StdioChild> {
let parts: Vec<&str> = command.split_whitespace().collect();
let (prog, prog_args) = parts
.split_first()
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
let mut cmd = std::process::Command::new(prog);
cmd.args(prog_args);
cmd.args(extra_args);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
// Pipe stderr so diagnostics from MCP servers are surfaced via tracing
// rather than discarded silently, making connectivity issues debugable.
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?;
let mut mcp = StdioChild {
stdin,
stdout: BufReader::new(stdout),
next_id: 0,
};
let deadline =
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
let init_result = mcp.call(
"initialize",
&json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "zesdex",
"version": "0.1.0"
}
}),
);
if std::time::Instant::now() > deadline {
anyhow::bail!("MCP initialize timed out");
}
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {e}"))?;
let _ = mcp.call("notifications/initialized", &json!({}));
Ok(mcp)
}
fn call_via_stdio(
existing_handle: Option<&Mutex<StdioChild>>,
command: &str,
extra_args: &[String],
tool_name: &str,
tool_args: &Value,
) -> anyhow::Result<String> {
// Reuse the persistent child handle if available; otherwise spawn a new one.
let mut guard;
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
guard = mtx
.lock()
.map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
&mut guard
} else {
let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call(
"tools/call",
&json!({
"name": tool_name,
"arguments": tool_args
}),
)?;
return Ok(extract_text_content(&result));
};
let result = child.call(
"tools/call",
&json!({
"name": tool_name,
"arguments": tool_args
}),
)?;
Ok(extract_text_content(&result))
}
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
.build()
.unwrap_or_else(|e| {
tracing::warn!(
"[mcp] HTTP client builder failed with connect timeout: {}. \
retrying without connect timeout",
e,
);
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.build()
.unwrap_or_else(|e2| {
tracing::warn!(
"[mcp] also failed: {}. using default client (no configured timeouts)",
e2,
);
reqwest::blocking::Client::new()
})
});
let request_id: u64 = 1;
let body = json!({
"jsonrpc": "2.0",
"id": request_id,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": tool_args
}
});
let resp = client
.post(url)
.header("Content-Type", "application/json")
.json(&body)
.send()
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
String::new()
});
anyhow::bail!("MCP HTTP server returned {status}: {text}");
}
let response: Value = resp
.json()
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
if let Some(err) = response.get("error") {
anyhow::bail!("MCP HTTP error: {err}");
}
let result = response.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] HTTP response missing 'result' field");
Value::Null
});
Ok(extract_text_content(&result))
}
fn extract_text_content(result: &Value) -> String {
if let Some(content) = result.get("content") {
if let Some(arr) = content.as_array() {
let text: Vec<String> = arr
.iter()
.filter_map(|item| {
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
item.get("text")
.and_then(|t| t.as_str())
.map(std::string::ToString::to_string)
} else {
None
}
})
.collect();
if !text.is_empty() {
return text.join("\n");
}
}
}
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
result.to_string()
})
}
/// Registry of connected MCP servers and their tools for the current session.
#[derive(Debug, Clone)]
pub struct McpManager {
pub servers: Vec<McpServer>,
}
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
/// be dispatched through the same execution path as built-in tools.
pub struct McpToolAdapter {
pub tool_name: String,
pub server_name: String,
pub transport: McpTransport,
pub description: String,
pub parameters: Value,
/// Shared handle to a persistent child process (stdio transport only).
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
}
impl crate::tool::Tool for McpToolAdapter {
fn name(&self) -> &'static str {
mcp_static_str(&format!("mcp__{}__{}", self.server_name, self.tool_name))
}
fn description(&self) -> &'static str {
mcp_static_str(&self.description)
}
fn parameters(&self) -> Value {
self.parameters.clone()
}
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
match &self.transport {
McpTransport::Stdio {
command,
args: extra_args,
} => call_via_stdio(
self.child_handle.as_ref().map(std::convert::AsRef::as_ref),
command,
extra_args,
&self.tool_name,
args,
),
McpTransport::StreamableHttp { url } => call_via_http(url, &self.tool_name, args),
}
}
}
impl McpManager {
/// Create an empty manager with no connected servers.
pub fn new() -> Self {
McpManager {
servers: Vec::new(),
}
}
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
///
/// Flow: for each server, clone its child handle → wrap each of its
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
///
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
/// stdio server reuses the same persistent child process/connection.
///
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
self.servers
.iter()
.flat_map(|server| {
let handle = server.child_handle.clone();
server.tools.iter().map(move |info| {
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
tool_name: info.name.clone(),
server_name: server.name.clone(),
transport: server.transport.clone(),
description: info.description.clone(),
parameters: info.input_schema.clone(),
child_handle: handle.clone(),
});
adapter
})
})
.collect()
}
/// Connects to an MCP server via stdio by spawning the child process, running
/// the `initialize` handshake, calling `tools/list`, and registering the server
/// with its advertised tools in `self.servers`. The child process stays alive
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
pub fn connect_stdio(
&mut self,
name: &str,
command: &str,
extra_args: &[String],
) -> anyhow::Result<()> {
let transport = McpTransport::Stdio {
command: command.to_string(),
args: extra_args.to_vec(),
};
let mut child = spawn_stdio_child(command, extra_args)?;
let result = child.call("tools/list", &json!({}))?;
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
tool_list
.iter()
.filter_map(|t| {
Some(McpToolInfo {
name: t.get("name")?.as_str()?.to_string(),
description: t
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
tracing::warn!(
"[mcp] tool {} missing description",
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
);
""
})
.to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
tracing::warn!(
"[mcp] tool {} missing inputSchema",
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
);
serde_json::Value::Null
}),
})
})
.collect()
} else {
Vec::new()
};
let handle = Arc::new(Mutex::new(child));
self.servers.push(McpServer {
name: name.to_string(),
transport,
tools,
child_handle: Some(handle),
});
Ok(())
}
}