Skip to main content

emperor_folder/
server.rs

1//! The MCP surface: seven tools over one [`Folder`], served over Streamable HTTP (emperor-mcp)
2//! or stdio (rmcp).
3//!
4//! File content is framed [`FrameKind::Document`] (a file's author is not known); structured
5//! results (listings, matches, metadata) are JSON framed [`FrameKind::Computed`]; small images
6//! come back as MCP image content. Refusals come back as tool errors (`isError: true`) with a
7//! message the model can act on.
8//!
9//! On a read-only server the mutating tools (`write`, `edit`, `move`) are removed from the tool
10//! router at construction, so `tools/list` does not offer them at all.
11
12use 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
26/// Server name reported to clients and by `doctor`.
27pub const SERVER_NAME: &str = "emperor-folder";
28
29/// Tools that change files; offered only when the server is writable.
30pub const WRITE_TOOLS: [&str; 3] = ["write", "edit", "move"];
31
32/// Arguments of `list`.
33#[derive(Debug, Deserialize, schemars::JsonSchema)]
34pub struct ListArgs {
35    /// Directory to list; omit or "." for the top.
36    #[serde(default)]
37    pub path: Option<String>,
38    /// Glob on paths as shown, e.g. "*.md" or "src/**/*.rs".
39    #[serde(default)]
40    pub glob: Option<String>,
41    /// Include subdirectories (default false: one level).
42    #[serde(default)]
43    pub recursive: bool,
44    /// Most entries to return; capped by the server limit.
45    #[serde(default)]
46    pub max_entries: Option<usize>,
47}
48
49/// Arguments of `read`.
50#[derive(Debug, Deserialize, schemars::JsonSchema)]
51pub struct ReadArgs {
52    /// File to read.
53    pub path: String,
54    /// First line to return, 1-based (default 1).
55    #[serde(default)]
56    pub start_line: Option<usize>,
57    /// Last line to return, inclusive (default: end of file).
58    #[serde(default)]
59    pub end_line: Option<usize>,
60}
61
62/// Arguments of `search`.
63#[derive(Debug, Deserialize, schemars::JsonSchema)]
64pub struct SearchArgs {
65    /// Regular expression (Rust regex syntax) matched against each line.
66    pub pattern: String,
67    /// Directory or file to search; omit for everything.
68    #[serde(default)]
69    pub path: Option<String>,
70    /// Glob on paths as shown, e.g. "*.rs".
71    #[serde(default)]
72    pub glob: Option<String>,
73    /// Match case-insensitively (default false).
74    #[serde(default)]
75    pub ignore_case: bool,
76}
77
78/// Arguments of `write`.
79#[derive(Debug, Deserialize, schemars::JsonSchema)]
80pub struct WriteArgs {
81    /// File to write; parent directories are created.
82    pub path: String,
83    /// Text to write.
84    pub content: String,
85    /// "create" (default, refuses an existing file), "overwrite" or "append".
86    #[serde(default)]
87    pub mode: WriteMode,
88}
89
90/// Arguments of `edit`.
91#[derive(Debug, Deserialize, schemars::JsonSchema)]
92pub struct EditArgs {
93    /// Text file to change.
94    pub path: String,
95    /// Exact text to replace, whitespace included; must occur exactly once unless replace_all.
96    pub old_text: String,
97    /// Replacement text (may be empty to delete old_text).
98    pub new_text: String,
99    /// Replace every occurrence (default false).
100    #[serde(default)]
101    pub replace_all: bool,
102}
103
104/// Arguments of `move`.
105#[derive(Debug, Deserialize, schemars::JsonSchema)]
106pub struct MoveArgs {
107    /// Existing file or directory.
108    pub from: String,
109    /// New path; parent directories are created.
110    pub to: String,
111    /// Replace an existing target (default false).
112    #[serde(default)]
113    pub overwrite: bool,
114}
115
116/// Arguments of `stat`.
117#[derive(Debug, Deserialize, schemars::JsonSchema)]
118pub struct StatArgs {
119    /// Path to describe; "." for the top.
120    pub path: String,
121}
122
123/// The MCP server: one [`Folder`] shared by every request.
124#[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    /// A server over `folder`. Without `--writable`, `write`, `edit` and `move` are not
134    /// registered.
135    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    /// The tool router for a writable or read-only server.
146    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    /// Number of tools the server exposes (for `doctor`'s tool budget).
157    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    /// Run a filesystem operation on the blocking pool.
321    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
342/// The instructions sent at `initialize`: how paths work and whether files can be changed.
343fn 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
385/// Hosts accepted in the `Host` header when none are configured: loopback names when the
386/// server listens on a loopback address (DNS-rebinding protection for a local file server),
387/// none (validation off, a validating ingress in front) otherwise.
388pub 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
399/// Serve over stateless Streamable HTTP at `/mcp` (plus `/health`) until Ctrl-C.
400pub 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
404/// Serve over stdio (desktop clients). Logs must go to stderr.
405pub 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}