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