1use std::net::SocketAddr;
13use std::sync::Arc;
14
15use base64::Engine;
16use emperor_mcp::schema::CompactSchemas;
17use emperor_mcp::{FrameKind, ServeOptions, frame};
18use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
19use rmcp::model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerConfig};
20use rmcp::{ServerHandler, ServiceExt, schemars, tool, tool_handler, tool_router};
21use serde::Deserialize;
22use serde_json::Value;
23
24use crate::error::FolderError;
25use crate::folder::{Folder, ReadOutput, WriteMode};
26
27pub const SERVER_NAME: &str = "emperor-folder";
29
30pub const WRITE_TOOLS: [&str; 3] = ["write", "edit", "move"];
32
33#[derive(Debug, Deserialize, schemars::JsonSchema)]
35pub struct ListArgs {
36 #[serde(default)]
38 pub path: Option<String>,
39 #[serde(default)]
41 pub glob: Option<String>,
42 #[serde(default)]
44 pub recursive: bool,
45 #[serde(default)]
47 pub max_entries: Option<usize>,
48}
49
50#[derive(Debug, Deserialize, schemars::JsonSchema)]
52pub struct ReadArgs {
53 pub path: String,
55 #[serde(default)]
57 pub start_line: Option<usize>,
58 #[serde(default)]
60 pub end_line: Option<usize>,
61}
62
63#[derive(Debug, Deserialize, schemars::JsonSchema)]
65pub struct SearchArgs {
66 pub pattern: String,
68 #[serde(default)]
70 pub path: Option<String>,
71 #[serde(default)]
73 pub glob: Option<String>,
74 #[serde(default)]
76 pub ignore_case: bool,
77}
78
79#[derive(Debug, Deserialize, schemars::JsonSchema)]
81pub struct WriteArgs {
82 pub path: String,
84 pub content: String,
86 #[serde(default)]
88 pub mode: WriteMode,
89}
90
91#[derive(Debug, Deserialize, schemars::JsonSchema)]
93pub struct EditArgs {
94 pub path: String,
96 pub old_text: String,
98 pub new_text: String,
100 #[serde(default)]
102 pub replace_all: bool,
103}
104
105#[derive(Debug, Deserialize, schemars::JsonSchema)]
107pub struct MoveArgs {
108 pub from: String,
110 pub to: String,
112 #[serde(default)]
114 pub overwrite: bool,
115}
116
117#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct StatArgs {
120 pub path: String,
122}
123
124#[derive(Clone)]
126pub struct FolderServer {
127 folder: Arc<Folder>,
128 tool_router: ToolRouter<Self>,
129 instructions: String,
130}
131
132#[tool_router]
133impl FolderServer {
134 pub fn new(folder: Arc<Folder>) -> Self {
137 let writable = folder.config().writable;
138 let instructions = instructions(&folder);
139 Self {
140 folder,
141 tool_router: Self::router(writable),
142 instructions,
143 }
144 }
145
146 pub fn router(writable: bool) -> ToolRouter<Self> {
148 let mut router = Self::tool_router().compact_schemas();
149 if !writable {
150 for name in WRITE_TOOLS {
151 router.remove_route(name);
152 }
153 }
154 router
155 }
156
157 pub fn tool_count(writable: bool) -> usize {
159 Self::router(writable).list_all().len()
160 }
161
162 #[tool(
163 name = "list",
164 description = "List files and directories in a folder. Skips hidden and .gitignored entries. Optional glob filter; recursive for subfolders.",
165 annotations(
166 read_only_hint = true,
167 destructive_hint = false,
168 idempotent_hint = true,
169 open_world_hint = false
170 )
171 )]
172 async fn list(&self, Parameters(a): Parameters<ListArgs>) -> CallToolResult {
173 let path = a.path.unwrap_or_default();
174 computed(
175 self.run(move |f| f.list(&path, a.glob.as_deref(), a.recursive, a.max_entries))
176 .await,
177 )
178 }
179
180 #[tool(
181 name = "read",
182 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.",
183 annotations(
184 read_only_hint = true,
185 destructive_hint = false,
186 idempotent_hint = true,
187 open_world_hint = false
188 )
189 )]
190 async fn read(&self, Parameters(a): Parameters<ReadArgs>) -> CallToolResult {
191 match self
192 .run(move |f| f.read(&a.path, a.start_line, a.end_line))
193 .await
194 {
195 Ok(ReadOutput::Text {
196 path,
197 content,
198 first_line,
199 last_line,
200 total_lines,
201 truncated,
202 partial_last_line,
203 }) => {
204 let total = total_lines.map_or_else(|| "?".to_string(), |n| n.to_string());
205 let mut header = format!("{path} lines {first_line}-{last_line} of {total}");
206 if truncated {
207 let next = if partial_last_line {
208 last_line
209 } else {
210 last_line + 1
211 };
212 header.push_str(&format!(
213 " (cut at the size limit; continue with start_line {next})"
214 ));
215 }
216 text(frame(FrameKind::Document, &format!("{header}\n{content}")))
217 }
218 Ok(ReadOutput::Image { path, mime, data }) => {
219 let about = serde_json::json!({ "path": path, "mime": mime, "size": data.len() });
220 let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
221 CallToolResult::success(vec![
222 ContentBlock::text(frame(FrameKind::Computed, &about.to_string())),
223 ContentBlock::image(b64, mime),
224 ])
225 }
226 Ok(ReadOutput::Binary { path, size }) => computed(Ok(serde_json::json!({
227 "path": path,
228 "binary": true,
229 "size": size,
230 "note": "binary file, content not returned",
231 }))),
232 Err(e) => error(e),
233 }
234 }
235
236 #[tool(
237 name = "search",
238 description = "Search text files for a regular expression; returns path, line number and line for each match. Optional start path, glob filter, ignore_case.",
239 annotations(
240 read_only_hint = true,
241 destructive_hint = false,
242 idempotent_hint = true,
243 open_world_hint = false
244 )
245 )]
246 async fn search(&self, Parameters(a): Parameters<SearchArgs>) -> CallToolResult {
247 let path = a.path.unwrap_or_default();
248 computed(
249 self.run(move |f| f.search(&a.pattern, &path, a.glob.as_deref(), a.ignore_case))
250 .await,
251 )
252 }
253
254 #[tool(
255 name = "write",
256 description = "Write text to a file. mode: create (default, fails if it exists), overwrite, or append. Creates parent directories.",
257 annotations(
258 read_only_hint = false,
259 destructive_hint = true,
260 idempotent_hint = false,
261 open_world_hint = false
262 )
263 )]
264 async fn write(&self, Parameters(a): Parameters<WriteArgs>) -> CallToolResult {
265 computed(
266 self.run(move |f| f.write(&a.path, &a.content, a.mode))
267 .await,
268 )
269 }
270
271 #[tool(
272 name = "edit",
273 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.",
274 annotations(
275 read_only_hint = false,
276 destructive_hint = true,
277 idempotent_hint = false,
278 open_world_hint = false
279 )
280 )]
281 async fn edit(&self, Parameters(a): Parameters<EditArgs>) -> CallToolResult {
282 computed(
283 self.run(move |f| f.edit(&a.path, &a.old_text, &a.new_text, a.replace_all))
284 .await,
285 )
286 }
287
288 #[tool(
289 name = "move",
290 description = "Move or rename a file or directory. Refuses to replace an existing target unless overwrite is true.",
291 annotations(
292 read_only_hint = false,
293 destructive_hint = true,
294 idempotent_hint = false,
295 open_world_hint = false
296 )
297 )]
298 async fn move_entry(&self, Parameters(a): Parameters<MoveArgs>) -> CallToolResult {
299 computed(
300 self.run(move |f| f.move_entry(&a.from, &a.to, a.overwrite))
301 .await,
302 )
303 }
304
305 #[tool(
306 name = "stat",
307 description = "Describe a path: kind (file, dir, symlink), size, modified time, line count for text files, image type for images.",
308 annotations(
309 read_only_hint = true,
310 destructive_hint = false,
311 idempotent_hint = true,
312 open_world_hint = false
313 )
314 )]
315 async fn stat(&self, Parameters(a): Parameters<StatArgs>) -> CallToolResult {
316 computed(self.run(move |f| f.stat(&a.path)).await)
317 }
318}
319
320impl FolderServer {
321 async fn run<T, F>(&self, op: F) -> Result<T, FolderError>
323 where
324 T: Send + 'static,
325 F: FnOnce(&Folder) -> Result<T, FolderError> + Send + 'static,
326 {
327 let folder = Arc::clone(&self.folder);
328 tokio::task::spawn_blocking(move || op(&folder))
329 .await
330 .unwrap_or_else(|e| Err(FolderError::Invalid(format!("internal error: {e}"))))
331 }
332}
333
334#[tool_handler(router = self.tool_router)]
335impl ServerHandler for FolderServer {
336 fn get_info(&self) -> ServerConfig {
337 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
338 .with_server_info(Implementation::new(SERVER_NAME, env!("CARGO_PKG_VERSION")))
339 .with_instructions(self.instructions.clone())
340 }
341}
342
343fn instructions(folder: &Folder) -> String {
345 let names: Vec<&str> = folder.roots().map(|(name, _)| name).collect();
346 let paths = if names.len() == 1 {
347 "Files in one folder. Every path is relative to that folder".to_string()
348 } else {
349 format!(
350 "Files in {} folders: {}. Every path starts with one of those names (e.g. {}/notes.md); \
351list \".\" shows them",
352 names.len(),
353 names.join(", "),
354 names[0]
355 )
356 };
357 let access = if folder.config().writable {
358 "write creates or replaces a file, edit replaces exact text in one, move renames."
359 } else {
360 "The server is read-only: no tool changes files."
361 };
362 format!(
363 "{paths}; absolute paths and `..` are refused. list and search skip hidden and \
364.gitignored files. {access}"
365 )
366}
367
368fn text(body: String) -> CallToolResult {
369 CallToolResult::success(vec![ContentBlock::text(body)])
370}
371
372fn error(e: FolderError) -> CallToolResult {
373 CallToolResult::error(vec![ContentBlock::text(e.to_string())])
374}
375
376fn computed(result: Result<Value, FolderError>) -> CallToolResult {
377 match result {
378 Ok(v) => {
379 let json = serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string());
380 text(frame(FrameKind::Computed, &json))
381 }
382 Err(e) => error(e),
383 }
384}
385
386pub fn default_allowed_hosts(bind: SocketAddr) -> Vec<String> {
390 if bind.ip().is_loopback() {
391 ["localhost", "127.0.0.1", "::1"]
392 .into_iter()
393 .map(String::from)
394 .collect()
395 } else {
396 Vec::new()
397 }
398}
399
400pub async fn serve_http(folder: Arc<Folder>, options: ServeOptions) -> anyhow::Result<()> {
402 emperor_mcp::serve(options, move || FolderServer::new(Arc::clone(&folder))).await
403}
404
405pub async fn serve_stdio(folder: Arc<Folder>) -> anyhow::Result<()> {
407 let service = FolderServer::new(folder)
408 .serve(rmcp::transport::stdio())
409 .await?;
410 service.waiting().await?;
411 Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn seven_tools_writable_four_read_only() {
420 assert_eq!(FolderServer::tool_count(true), 7);
421 assert_eq!(FolderServer::tool_count(false), 4);
422 let ro = FolderServer::router(false);
423 assert!(WRITE_TOOLS.iter().all(|t| !ro.has_route(t)));
424 }
425
426 #[test]
427 fn tools_conform_in_both_modes() {
428 use emperor_mcp::testkit;
429 let writable = FolderServer::router(true).list_all();
430 let read_only = FolderServer::router(false).list_all();
431 for tools in [&writable, &read_only] {
432 testkit::assert_tools(tools);
433 testkit::assert_compact_schemas(tools);
434 assert!(
435 testkit::warnings(tools).is_empty(),
436 "{:?}",
437 testkit::warnings(tools)
438 );
439 }
440 testkit::assert_read_only_default(&read_only);
441 }
442
443 #[test]
444 fn loopback_bind_validates_host() {
445 assert!(!default_allowed_hosts("127.0.0.1:8031".parse().unwrap()).is_empty());
446 assert!(default_allowed_hosts("0.0.0.0:8031".parse().unwrap()).is_empty());
447 }
448}