Skip to main content

winx_code_agent/
server.rs

1//! Winx MCP Server implementation using rmcp 0.12.0
2//! Core MCP tools only - High performance shell and file management
3
4use rmcp::{
5    model::{
6        Annotated, CallToolRequestParams, CallToolResult, Content, GetPromptRequestParams,
7        GetPromptResult, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
8        PaginatedRequestParams, Prompt, PromptMessage, PromptMessageRole, ProtocolVersion,
9        RawResource, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
10        ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
11    },
12    service::{RequestContext, RoleServer},
13    transport::stdio,
14    ErrorData as McpError, ServerHandler, ServiceExt,
15};
16use schemars::schema_for;
17use serde_json::Value;
18use std::fmt::Write as FmtWrite;
19use std::path::Path;
20use std::process::Command;
21use std::sync::{Arc, OnceLock};
22use tokio::sync::Mutex;
23use tracing::{info, warn};
24
25use crate::state::BashState;
26use crate::types::{BashCommand, ContextSave, FileWriteOrEdit, Initialize, ReadFiles, ReadImage};
27
28/// Type alias for the shared bash state - uses `tokio::sync::Mutex` for async safety
29pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
30
31/// Helper function to create JSON schema from schemars Schema
32fn schema_to_input_schema<T: schemars::JsonSchema>() -> Arc<serde_json::Map<String, Value>> {
33    let schema = schema_for!(T);
34    let value = serde_json::to_value(schema).unwrap_or(Value::Object(serde_json::Map::new()));
35    match value {
36        Value::Object(map) => Arc::new(map),
37        _ => Arc::new(serde_json::Map::new()),
38    }
39}
40
41fn mcp_tool<T: schemars::JsonSchema>(
42    name: &'static str,
43    description: &'static str,
44    annotations: ToolAnnotations,
45) -> Tool {
46    Tool::new(name, description, schema_to_input_schema::<T>()).with_annotations(annotations)
47}
48
49const INITIALIZE_DESCRIPTION: &str =
50    "- Always call this at the start of the conversation before using any of the shell tools from wcgw. \
51     - Use `any_workspace_path` to initialize the shell in the appropriate project directory. \
52     - If the user has mentioned a workspace or project root or any other file or folder use it to set `any_workspace_path`. \
53     - If user has mentioned any files use `initial_files_to_read` to read, use absolute paths only (~ allowed) \
54     - By default use mode \"wcgw\" \
55     - In \"code-writer\" mode, set the commands and globs which user asked to set, otherwise use 'all'. \
56     - Use type=\"first_call\" if it's the first call to this tool. \
57     - Use type=\"user_asked_mode_change\" if in a conversation user has asked to change mode. \
58     - Use type=\"reset_shell\" if in a conversation shell is not working after multiple tries. \
59     - Use type=\"user_asked_change_workspace\" if in a conversation user asked to change workspace";
60
61const BASH_COMMAND_DESCRIPTION: &str =
62    "- Execute a bash command. This is stateful (beware with subsequent calls). \
63     - Accepted payloads include action_json with an explicit type, action_json shorthand such as {\"command\":\"pwd\"}, or top-level shorthand such as {\"command\":\"pwd\"}. \
64     - Status of the command and the current working directory will always be returned at the end. \
65     - The first or the last line might be `(...truncated)` if the output is too long. \
66     - Always run `pwd` if you get any file or directory not found error to make sure you're not lost. \
67     - Do not run bg commands using \"&\", instead use this tool. \
68     - You must not use echo/cat to read/write files, use ReadFiles/FileWriteOrEdit \
69     - In order to check status of previous command, use `status_check` with empty command argument. \
70     - Only command is allowed to run at a time. You need to wait for any previous command to finish before running a new one. \
71     - Programs don't hang easily, so most likely explanation for no output is usually that the program is still running, and you need to check status again. \
72     - Do not send Ctrl-c before checking for status till 10 minutes or whatever is appropriate for the program to finish. \
73     - Only run long running commands in background. Each background command is run in a new non-reusable shell. \
74     - On running a bg command you'll get a bg command id that you should use to get status or interact.";
75
76const READ_FILES_DESCRIPTION: &str =
77    "- Read full file content of one or more files. \
78     - Prefer this over reading files with BashCommand (cat/head/tail): the output is token-budgeted and the read is recorded so FileWriteOrEdit can edit the file afterward. \
79     - Do NOT use this for binary files or images — use ReadImage for images. \
80     - Provide absolute paths only (~ allowed) \
81     - Only if the task requires line numbers understanding: \
82     - You may extract a range of lines. E.g., `/path/to/file:1-10` for lines 1-10. You can drop start or end like `/path/to/file:1-` or `/path/to/file:-10`";
83
84const FILE_WRITE_OR_EDIT_DESCRIPTION: &str =
85    "- Writes or edits a file based on the percentage of changes. \
86     - Prefer this over writing/editing files with BashCommand (echo/sed/redirects/heredocs). \
87     - For an edit, the file must have been read first with ReadFiles, otherwise the edit is rejected. \
88     - Use absolute path only (~ allowed). \
89     - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change \
90     - percentage_to_change should be low if mostly new code is to be added. It should be high if a lot of things are to be replaced. \
91     - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks \
92     - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks. \
93     \
94     Instructions for editing files. \
95     # Example \
96     ## Input file \
97     ``` \
98     import numpy as np \
99     from impls import impl1, impl2 \
100     \
101     def hello(): \
102         \"print a greeting\" \
103         print(\"hello\") \
104     \
105     def call_hello(): \
106         \"call hello\" \
107         hello() \
108         print(\"Called\") \
109         impl1() \
110         hello() \
111         impl2() \
112     ``` \
113     ## Edit format on the input file \
114     ``` \
115     <<<<<<< SEARCH \
116     from impls import impl1, impl2 \
117     ======= \
118     from impls import impl1, impl2 \
119     from hello import hello as hello_renamed \
120     >>>>>>> REPLACE \
121     <<<<<<< SEARCH \
122     def hello(): \
123         \"print a greeting\" \
124         print(\"hello\") \
125     ======= \
126     >>>>>>> REPLACE \
127     <<<<<<< SEARCH \
128     def call_hello(): \
129         \"call hello\" \
130         hello() \
131     ======= \
132     def call_hello_renamed(): \
133         \"call hello renamed\" \
134         hello_renamed() \
135     >>>>>>> REPLACE \
136     <<<<<<< SEARCH \
137     impl1() \
138     hello() \
139     impl2() \
140     ======= \
141     impl1() \
142     hello_renamed() \
143     impl2() \
144     >>>>>>> REPLACE \
145     ``` \
146     # *SEARCH/REPLACE block* Rules: \
147     Every \"<<<<<<< SEARCH\" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc. \
148     Including multiple unique *SEARCH/REPLACE* blocks if needed. \
149     Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change. \
150     Keep *SEARCH/REPLACE* blocks concise. \
151     Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. \
152     Include just the changing lines, and a few surrounding lines (0-3 lines) if needed for uniqueness. \
153     Other than for uniqueness, avoid including those lines which do not change in search (and replace) blocks. Target 0-3 non trivial extra lines per block. \
154     Preserve leading spaces and indentations in both SEARCH and REPLACE blocks.";
155
156const CONTEXT_SAVE_DESCRIPTION: &str =
157    "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
158     - Provide random 3 word unqiue id or whatever user provided. \
159     - Leave project path as empty string if no project path";
160
161static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
162static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
163
164fn winx_tools() -> Vec<Tool> {
165    WINX_TOOLS.get_or_init(build_winx_tools).clone()
166}
167
168fn build_winx_tools() -> Vec<Tool> {
169    vec![
170        mcp_tool::<Initialize>(
171            "Initialize",
172            INITIALIZE_DESCRIPTION,
173            ToolAnnotations::new().read_only(true).open_world(false),
174        ),
175        mcp_tool::<BashCommand>(
176            "BashCommand",
177            BASH_COMMAND_DESCRIPTION,
178            ToolAnnotations::new().destructive(true).open_world(true),
179        ),
180        mcp_tool::<ReadFiles>(
181            "ReadFiles",
182            READ_FILES_DESCRIPTION,
183            ToolAnnotations::new().read_only(true).open_world(false),
184        ),
185        mcp_tool::<FileWriteOrEdit>(
186            "FileWriteOrEdit",
187            FILE_WRITE_OR_EDIT_DESCRIPTION,
188            ToolAnnotations::new().destructive(true).open_world(false),
189        ),
190        mcp_tool::<ContextSave>(
191            "ContextSave",
192            CONTEXT_SAVE_DESCRIPTION,
193            ToolAnnotations::new().destructive(false).open_world(false),
194        ),
195        mcp_tool::<ReadImage>(
196            "ReadImage",
197            "Read an image from the shell.",
198            ToolAnnotations::new().read_only(true).open_world(false),
199        ),
200    ]
201}
202
203fn winx_prompts() -> Vec<Prompt> {
204    WINX_PROMPTS
205        .get_or_init(|| {
206            vec![Prompt::new(
207                "KnowledgeTransfer",
208                Some("Summarize current Winx state, workspace context, and handoff notes."),
209                None,
210            )]
211        })
212        .clone()
213}
214
215fn append_command_section<const N: usize>(
216    output: &mut String,
217    title: &str,
218    cwd: &Path,
219    args: [&str; N],
220) {
221    let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
222        return;
223    };
224    if !command_output.status.success() {
225        return;
226    }
227
228    let content = String::from_utf8_lossy(&command_output.stdout);
229    if content.trim().is_empty() {
230        return;
231    }
232
233    let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
234}
235
236/// Winx service with shared bash state
237#[derive(Clone)]
238pub struct WinxService {
239    /// Shared state for the bash shell environment (async-safe)
240    pub bash_state: SharedBashState,
241    /// Version information for the service
242    pub version: String,
243}
244
245impl Default for WinxService {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251impl WinxService {
252    /// Create a new `WinxService` instance
253    pub fn new() -> Self {
254        info!("Creating new WinxService instance");
255        Self {
256            bash_state: Arc::new(Mutex::new(None)),
257            version: env!("CARGO_PKG_VERSION").to_string(),
258        }
259    }
260}
261
262/// `ServerHandler` implementation
263impl ServerHandler for WinxService {
264    fn get_info(&self) -> ServerInfo {
265        ServerInfo::new(
266            ServerCapabilities::builder()
267                .enable_tools()
268                .enable_resources()
269                .enable_prompts()
270                .build(),
271        )
272        .with_server_info(
273            Implementation::new("winx-mcp-server", self.version.clone())
274                .with_title("Winx High-Performance MCP"),
275        )
276        .with_protocol_version(ProtocolVersion::V_2024_11_05)
277        .with_instructions(
278                "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
279        )
280    }
281
282    async fn list_tools(
283        &self,
284        _request: Option<PaginatedRequestParams>,
285        _context: RequestContext<RoleServer>,
286    ) -> Result<ListToolsResult, McpError> {
287        Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
288    }
289
290    async fn list_resources(
291        &self,
292        _param: Option<PaginatedRequestParams>,
293        _context: RequestContext<RoleServer>,
294    ) -> Result<ListResourcesResult, McpError> {
295        Ok(ListResourcesResult {
296            resources: vec![Annotated {
297                raw: RawResource {
298                    uri: "file://readme".into(),
299                    name: "README".into(),
300                    description: Some("Project README documentation".into()),
301                    mime_type: Some("text/markdown".into()),
302                    size: None,
303                    title: None,
304                    icons: None,
305                    meta: None,
306                },
307                annotations: None,
308            }],
309            next_cursor: None,
310            meta: None,
311        })
312    }
313
314    async fn list_prompts(
315        &self,
316        _request: Option<PaginatedRequestParams>,
317        _context: RequestContext<RoleServer>,
318    ) -> Result<ListPromptsResult, McpError> {
319        Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
320    }
321
322    async fn get_prompt(
323        &self,
324        request: GetPromptRequestParams,
325        _context: RequestContext<RoleServer>,
326    ) -> Result<GetPromptResult, McpError> {
327        if request.name != "KnowledgeTransfer" {
328            return Err(McpError::invalid_request(
329                format!("Unknown prompt: {}", request.name),
330                None,
331            ));
332        }
333
334        let text = self.knowledge_transfer_prompt_text().await;
335
336        Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
337            .with_description("Knowledge transfer handoff prompt"))
338    }
339
340    async fn read_resource(
341        &self,
342        param: ReadResourceRequestParams,
343        _context: RequestContext<RoleServer>,
344    ) -> Result<ReadResourceResult, McpError> {
345        let content = match param.uri.as_ref() {
346            "file://readme" => match tokio::fs::read_to_string("README.md").await {
347                Ok(content) => vec![ResourceContents::text(content, param.uri.clone())],
348                Err(_) => vec![ResourceContents::text(
349                    "README.md not found".to_string(),
350                    param.uri.clone(),
351                )],
352            },
353            _ => {
354                return Err(McpError::invalid_request(
355                    format!("Unknown resource URI: {}", param.uri),
356                    None,
357                ));
358            }
359        };
360
361        Ok(ReadResourceResult::new(content))
362    }
363
364    async fn call_tool(
365        &self,
366        param: CallToolRequestParams,
367        _context: RequestContext<RoleServer>,
368    ) -> Result<CallToolResult, McpError> {
369        let tool = param.name.to_string();
370        let args_value = param.arguments.map(Value::Object);
371        // Audit trail: one structured line per tool call, including the outcome
372        // and wall-clock. Successes were previously silent — only errors logged —
373        // which made debugging remote (ChatGPT) sessions guesswork.
374        let summary = audit_summary(&tool, args_value.as_ref());
375        let started = std::time::Instant::now();
376
377        let result = match tool.as_str() {
378            "Initialize" => self.handle_initialize(args_value).await,
379            "BashCommand" => self.handle_bash_command(args_value).await,
380            "ReadFiles" => self.handle_read_files(args_value).await,
381            "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
382            "ContextSave" => self.handle_context_save(args_value).await,
383            "ReadImage" => self.handle_read_image(args_value).await,
384            _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
385        };
386
387        let ms = started.elapsed().as_millis();
388        match &result {
389            Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
390            Err(error) => warn!(tool = %tool, ms, "tool call error — {summary}: {}", error.message),
391        }
392        result
393    }
394}
395
396/// Build a short, non-sensitive audit summary of a tool call's arguments.
397fn audit_summary(tool: &str, args: Option<&Value>) -> String {
398    let Some(args) = args else {
399        return "(no args)".to_string();
400    };
401    let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
402    let clip = |text: String| text.chars().take(100).collect::<String>();
403    match tool {
404        "BashCommand" => {
405            let action = args.get("action_json");
406            let cmd = action
407                .and_then(|a| a.get("command"))
408                .and_then(Value::as_str)
409                .or_else(|| args.get("command").and_then(Value::as_str));
410            if let Some(cmd) = cmd {
411                format!("cmd={:?}", clip(cmd.to_string()))
412            } else {
413                let kind =
414                    action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
415                format!("action={kind}")
416            }
417        }
418        "FileWriteOrEdit" | "ReadImage" => format!("path={}", s("file_path")),
419        "ReadFiles" => {
420            format!(
421                "files={}",
422                args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
423            )
424        }
425        "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
426        "ContextSave" => format!("id={}", s("id")),
427        _ => String::new(),
428    }
429}
430
431impl WinxService {
432    async fn knowledge_transfer_prompt_text(&self) -> String {
433        let mut text = String::from(
434            "Prepare a concise handoff for another agent. Include active objective, current state, important files, changed files, blockers, validation already run, and exact next commands.\n",
435        );
436
437        let state_snapshot = {
438            let guard = self.bash_state.lock().await;
439            guard.as_ref().map(|state| {
440                let whitelist = state
441                    .whitelist_for_overwrite
442                    .iter()
443                    .take(12)
444                    .map(|(path, data)| {
445                        format!(
446                            "- {} ({:.1}% read, {} lines)",
447                            path,
448                            data.get_percentage_read(),
449                            data.total_lines
450                        )
451                    })
452                    .collect::<Vec<_>>();
453                (
454                    state.current_thread_id.clone(),
455                    state.workspace_root.clone(),
456                    state.cwd.clone(),
457                    state.mode.to_string(),
458                    whitelist,
459                    state.whitelist_for_overwrite.len(),
460                )
461            })
462        };
463
464        let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
465            state_snapshot
466        else {
467            text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
468            return text;
469        };
470
471        let _ = writeln!(
472            text,
473            "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
474            workspace_root.display(),
475            cwd.display()
476        );
477
478        if !whitelist.is_empty() {
479            text.push_str("\n# Recently readable files\n");
480            text.push_str(&whitelist.join("\n"));
481            text.push('\n');
482        }
483
484        let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
485        if !active_files.is_empty() {
486            text.push_str("\n# Active files by Winx usage\n");
487            for file in active_files.iter().take(12) {
488                let _ = writeln!(text, "- {file}");
489            }
490        }
491
492        if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
493            let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
494            let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
495        }
496
497        append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
498        append_command_section(
499            &mut text,
500            "Git diff stat",
501            &workspace_root,
502            ["diff", "--stat", "HEAD"],
503        );
504
505        // Sections the ContextSave `description` should contain, tailored to the
506        // mode: architect produces a plan (no edits), the others produce a status
507        // + pending-issues handoff (wcgw parity: WCGW_KT vs ARCHITECT_KT).
508        let sections = if mode == "architect" {
509            "\n# Sections for the ContextSave description (architect mode)\n\
510             - `# Objective` — project and task objective.\n\
511             - `# All user instructions` — everything the user asked, verbatim.\n\
512             - `# Designed plan` — the plan you designed, in detail.\n\
513             - Provide all relevant file paths so the next agent can resume; err toward more.\n"
514        } else {
515            "\n# Sections for the ContextSave description\n\
516             - `# Objective` — project and task objective.\n\
517             - `# All user instructions` — everything the user asked, verbatim.\n\
518             - `# Current status` — what's already done (not what's left).\n\
519             - `# Pending issues with snippets` — verbatim errors/tracebacks/commands; be verbose.\n\
520             - `# Build and development instructions` — how to build/run/test; leave empty if unknown.\n\
521             - Provide all relevant file paths so the next agent can resume; err toward more.\n"
522        };
523        text.push_str(sections);
524
525        text.push_str(
526            "\n# Handoff checklist\n- State what changed and why.\n- Include files touched and any user-owned dirty work to preserve.\n- Include validation commands already run and their result.\n- Include the next safest command to continue.\n",
527        );
528
529        text
530    }
531
532    async fn persist_state(&self) {
533        let guard = self.bash_state.lock().await;
534        if let Some(state) = guard.as_ref() {
535            if let Err(error) = state.save_state_to_disk() {
536                warn!("Failed to persist bash state: {}", error);
537            }
538        }
539    }
540
541    /// Deserialize `args` into `T`, retrying once after JSON-decoding any string
542    /// field that is itself an encoded object/array. LLMs sometimes send a nested
543    /// param (e.g. `code_writer_config`) as a JSON string instead of an object;
544    /// wcgw applies the same leniency in its tool dispatch.
545    fn lenient_from_value<T: serde::de::DeserializeOwned>(
546        args: Value,
547    ) -> Result<T, serde_json::Error> {
548        match serde_json::from_value::<T>(args.clone()) {
549            Ok(value) => Ok(value),
550            Err(first_err) => {
551                let Value::Object(mut map) = args else {
552                    return Err(first_err);
553                };
554                let mut changed = false;
555                for value in map.values_mut() {
556                    if let Value::String(text) = value {
557                        if let Ok(parsed) = serde_json::from_str::<Value>(text) {
558                            if parsed.is_object() || parsed.is_array() {
559                                *value = parsed;
560                                changed = true;
561                            }
562                        }
563                    }
564                }
565                if changed {
566                    serde_json::from_value::<T>(Value::Object(map))
567                } else {
568                    Err(first_err)
569                }
570            }
571        }
572    }
573
574    async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
575        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
576        let initialize: Initialize = Self::lenient_from_value(args).map_err(|e| {
577            McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
578        })?;
579
580        match crate::tools::initialize::handle_tool_call(&self.bash_state, initialize).await {
581            Ok(result) => {
582                self.persist_state().await;
583                Ok(CallToolResult::success(vec![Content::text(result)]))
584            }
585            Err(e) => Err(McpError::internal_error(format!("Initialize failed: {e}"), None)),
586        }
587    }
588
589    async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
590        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
591        let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
592            McpError::invalid_request(
593                format!(
594                    "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
595                ),
596                None,
597            )
598        })?;
599
600        match crate::tools::bash_command::handle_tool_call(&self.bash_state, bash_command).await {
601            Ok(output) => {
602                self.persist_state().await;
603                Ok(CallToolResult::success(vec![Content::text(output)]))
604            }
605            Err(e) => Err(McpError::internal_error(format!("BashCommand failed: {e}"), None)),
606        }
607    }
608
609    async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
610        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
611        let read_files: ReadFiles = Self::lenient_from_value(args).map_err(|e| {
612            McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
613        })?;
614
615        match crate::tools::read_files::handle_tool_call(&self.bash_state, read_files).await {
616            Ok(result) => {
617                self.persist_state().await;
618                Ok(CallToolResult::success(vec![Content::text(result)]))
619            }
620            Err(e) => Err(McpError::internal_error(format!("ReadFiles failed: {e}"), None)),
621        }
622    }
623
624    async fn handle_file_write_or_edit(
625        &self,
626        args: Option<Value>,
627    ) -> Result<CallToolResult, McpError> {
628        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
629        let file_write_or_edit: FileWriteOrEdit = Self::lenient_from_value(args).map_err(|e| {
630            McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
631        })?;
632
633        match crate::tools::file_write_or_edit::handle_tool_call(
634            &self.bash_state,
635            file_write_or_edit,
636        )
637        .await
638        {
639            Ok(result) => {
640                self.persist_state().await;
641                Ok(CallToolResult::success(vec![Content::text(result)]))
642            }
643            Err(e) => Err(McpError::internal_error(format!("FileWriteOrEdit failed: {e}"), None)),
644        }
645    }
646
647    async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
648        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
649        let context_save: ContextSave = Self::lenient_from_value(args).map_err(|e| {
650            McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
651        })?;
652
653        match crate::tools::context_save::handle_tool_call(&self.bash_state, context_save).await {
654            Ok(result) => {
655                self.persist_state().await;
656                Ok(CallToolResult::success(vec![Content::text(result)]))
657            }
658            Err(e) => Err(McpError::internal_error(format!("ContextSave failed: {e}"), None)),
659        }
660    }
661
662    async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
663        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
664        let read_image: ReadImage = Self::lenient_from_value(args).map_err(|e| {
665            McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
666        })?;
667
668        match crate::tools::read_image::handle_tool_call(&self.bash_state, read_image).await {
669            Ok((mime_type, base64_data)) => {
670                self.persist_state().await;
671                // Return a real image content block (not base64 as text) so the
672                // model can actually see the image. rmcp's `Content::image`
673                // takes (data, mime_type).
674                Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
675            }
676            Err(e) => Err(McpError::internal_error(format!("ReadImage failed: {e}"), None)),
677        }
678    }
679}
680
681/// Create and start the Winx MCP server
682pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
683    info!("Starting Winx MCP Server");
684    let service = WinxService::new();
685    let server = service.serve(stdio()).await?;
686    server.waiting().await?;
687    Ok(())
688}