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