use std::net::SocketAddr;
use std::sync::Arc;
use base64::Engine;
use emperor_mcp::schema::CompactSchemas;
use emperor_mcp::{FrameKind, ServeOptions, frame};
use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
use rmcp::model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerConfig};
use rmcp::{ServerHandler, ServiceExt, schemars, tool, tool_handler, tool_router};
use serde::Deserialize;
use serde_json::Value;
use crate::error::FolderError;
use crate::folder::{Folder, ReadOutput, WriteMode};
pub const SERVER_NAME: &str = "emperor-folder";
pub const WRITE_TOOLS: [&str; 3] = ["write", "edit", "move"];
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListArgs {
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub glob: Option<String>,
#[serde(default)]
pub recursive: bool,
#[serde(default)]
pub max_entries: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadArgs {
pub path: String,
#[serde(default)]
pub start_line: Option<usize>,
#[serde(default)]
pub end_line: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchArgs {
pub pattern: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub glob: Option<String>,
#[serde(default)]
pub ignore_case: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct WriteArgs {
pub path: String,
pub content: String,
#[serde(default)]
pub mode: WriteMode,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EditArgs {
pub path: String,
pub old_text: String,
pub new_text: String,
#[serde(default)]
pub replace_all: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MoveArgs {
pub from: String,
pub to: String,
#[serde(default)]
pub overwrite: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StatArgs {
pub path: String,
}
#[derive(Clone)]
pub struct FolderServer {
folder: Arc<Folder>,
tool_router: ToolRouter<Self>,
instructions: String,
}
#[tool_router]
impl FolderServer {
pub fn new(folder: Arc<Folder>) -> Self {
let writable = folder.config().writable;
let instructions = instructions(&folder);
Self {
folder,
tool_router: Self::router(writable),
instructions,
}
}
pub fn router(writable: bool) -> ToolRouter<Self> {
let mut router = Self::tool_router().compact_schemas();
if !writable {
for name in WRITE_TOOLS {
router.remove_route(name);
}
}
router
}
pub fn tool_count(writable: bool) -> usize {
Self::router(writable).list_all().len()
}
#[tool(
name = "list",
description = "List files and directories in a folder. Skips hidden and .gitignored entries. Optional glob filter; recursive for subfolders.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn list(&self, Parameters(a): Parameters<ListArgs>) -> CallToolResult {
let path = a.path.unwrap_or_default();
computed(
self.run(move |f| f.list(&path, a.glob.as_deref(), a.recursive, a.max_entries))
.await,
)
}
#[tool(
name = "read",
description = "Read a text file, optionally only lines start_line..end_line (1-based, inclusive). Small PNG, JPEG, GIF and WebP images come back as images; other binary files are reported, not returned.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn read(&self, Parameters(a): Parameters<ReadArgs>) -> CallToolResult {
match self
.run(move |f| f.read(&a.path, a.start_line, a.end_line))
.await
{
Ok(ReadOutput::Text {
path,
content,
first_line,
last_line,
total_lines,
truncated,
partial_last_line,
}) => {
let total = total_lines.map_or_else(|| "?".to_string(), |n| n.to_string());
let mut header = format!("{path} lines {first_line}-{last_line} of {total}");
if truncated {
let next = if partial_last_line {
last_line
} else {
last_line + 1
};
header.push_str(&format!(
" (cut at the size limit; continue with start_line {next})"
));
}
text(frame(FrameKind::Document, &format!("{header}\n{content}")))
}
Ok(ReadOutput::Image { path, mime, data }) => {
let about = serde_json::json!({ "path": path, "mime": mime, "size": data.len() });
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
CallToolResult::success(vec![
ContentBlock::text(frame(FrameKind::Computed, &about.to_string())),
ContentBlock::image(b64, mime),
])
}
Ok(ReadOutput::Binary { path, size }) => computed(Ok(serde_json::json!({
"path": path,
"binary": true,
"size": size,
"note": "binary file, content not returned",
}))),
Err(e) => error(e),
}
}
#[tool(
name = "search",
description = "Search text files for a regular expression; returns path, line number and line for each match. Optional start path, glob filter, ignore_case.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn search(&self, Parameters(a): Parameters<SearchArgs>) -> CallToolResult {
let path = a.path.unwrap_or_default();
computed(
self.run(move |f| f.search(&a.pattern, &path, a.glob.as_deref(), a.ignore_case))
.await,
)
}
#[tool(
name = "write",
description = "Write text to a file. mode: create (default, fails if it exists), overwrite, or append. Creates parent directories.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn write(&self, Parameters(a): Parameters<WriteArgs>) -> CallToolResult {
computed(
self.run(move |f| f.write(&a.path, &a.content, a.mode))
.await,
)
}
#[tool(
name = "edit",
description = "Replace exact text in a text file: old_text must occur exactly once (copy it from read, whitespace included) unless replace_all is true.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn edit(&self, Parameters(a): Parameters<EditArgs>) -> CallToolResult {
computed(
self.run(move |f| f.edit(&a.path, &a.old_text, &a.new_text, a.replace_all))
.await,
)
}
#[tool(
name = "move",
description = "Move or rename a file or directory. Refuses to replace an existing target unless overwrite is true.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn move_entry(&self, Parameters(a): Parameters<MoveArgs>) -> CallToolResult {
computed(
self.run(move |f| f.move_entry(&a.from, &a.to, a.overwrite))
.await,
)
}
#[tool(
name = "stat",
description = "Describe a path: kind (file, dir, symlink), size, modified time, line count for text files, image type for images.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn stat(&self, Parameters(a): Parameters<StatArgs>) -> CallToolResult {
computed(self.run(move |f| f.stat(&a.path)).await)
}
}
impl FolderServer {
async fn run<T, F>(&self, op: F) -> Result<T, FolderError>
where
T: Send + 'static,
F: FnOnce(&Folder) -> Result<T, FolderError> + Send + 'static,
{
let folder = Arc::clone(&self.folder);
tokio::task::spawn_blocking(move || op(&folder))
.await
.unwrap_or_else(|e| Err(FolderError::Invalid(format!("internal error: {e}"))))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for FolderServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(SERVER_NAME, env!("CARGO_PKG_VERSION")))
.with_instructions(self.instructions.clone())
}
}
fn instructions(folder: &Folder) -> String {
let names: Vec<&str> = folder.roots().map(|(name, _)| name).collect();
let paths = if names.len() == 1 {
"Files in one folder. Every path is relative to that folder".to_string()
} else {
format!(
"Files in {} folders: {}. Every path starts with one of those names (e.g. {}/notes.md); \
list \".\" shows them",
names.len(),
names.join(", "),
names[0]
)
};
let access = if folder.config().writable {
"write creates or replaces a file, edit replaces exact text in one, move renames."
} else {
"The server is read-only: no tool changes files."
};
format!(
"{paths}; absolute paths and `..` are refused. list and search skip hidden and \
.gitignored files. {access}"
)
}
fn text(body: String) -> CallToolResult {
CallToolResult::success(vec![ContentBlock::text(body)])
}
fn error(e: FolderError) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(e.to_string())])
}
fn computed(result: Result<Value, FolderError>) -> CallToolResult {
match result {
Ok(v) => {
let json = serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string());
text(frame(FrameKind::Computed, &json))
}
Err(e) => error(e),
}
}
pub fn default_allowed_hosts(bind: SocketAddr) -> Vec<String> {
if bind.ip().is_loopback() {
["localhost", "127.0.0.1", "::1"]
.into_iter()
.map(String::from)
.collect()
} else {
Vec::new()
}
}
pub async fn serve_http(folder: Arc<Folder>, options: ServeOptions) -> anyhow::Result<()> {
emperor_mcp::serve(options, move || FolderServer::new(Arc::clone(&folder))).await
}
pub async fn serve_stdio(folder: Arc<Folder>) -> anyhow::Result<()> {
let service = FolderServer::new(folder)
.serve(rmcp::transport::stdio())
.await?;
service.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seven_tools_writable_four_read_only() {
assert_eq!(FolderServer::tool_count(true), 7);
assert_eq!(FolderServer::tool_count(false), 4);
let ro = FolderServer::router(false);
assert!(WRITE_TOOLS.iter().all(|t| !ro.has_route(t)));
}
#[test]
fn tools_conform_in_both_modes() {
use emperor_mcp::testkit;
let writable = FolderServer::router(true).list_all();
let read_only = FolderServer::router(false).list_all();
for tools in [&writable, &read_only] {
testkit::assert_tools(tools);
testkit::assert_compact_schemas(tools);
assert!(
testkit::warnings(tools).is_empty(),
"{:?}",
testkit::warnings(tools)
);
}
testkit::assert_read_only_default(&read_only);
}
#[test]
fn loopback_bind_validates_host() {
assert!(!default_allowed_hosts("127.0.0.1:8031".parse().unwrap()).is_empty());
assert!(default_allowed_hosts("0.0.0.0:8031".parse().unwrap()).is_empty());
}
}