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     - Provide absolute paths only (~ allowed) \
79     - Only if the task requires line numbers understanding: \
80     - 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`";
81
82const FILE_WRITE_OR_EDIT_DESCRIPTION: &str =
83    "- Writes or edits a file based on the percentage of changes. \
84     - Use absolute path only (~ allowed). \
85     - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change \
86     - 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. \
87     - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks \
88     - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks. \
89     \
90     Instructions for editing files. \
91     # Example \
92     ## Input file \
93     ``` \
94     import numpy as np \
95     from impls import impl1, impl2 \
96     \
97     def hello(): \
98         \"print a greeting\" \
99         print(\"hello\") \
100     \
101     def call_hello(): \
102         \"call hello\" \
103         hello() \
104         print(\"Called\") \
105         impl1() \
106         hello() \
107         impl2() \
108     ``` \
109     ## Edit format on the input file \
110     ``` \
111     <<<<<<< SEARCH \
112     from impls import impl1, impl2 \
113     ======= \
114     from impls import impl1, impl2 \
115     from hello import hello as hello_renamed \
116     >>>>>>> REPLACE \
117     <<<<<<< SEARCH \
118     def hello(): \
119         \"print a greeting\" \
120         print(\"hello\") \
121     ======= \
122     >>>>>>> REPLACE \
123     <<<<<<< SEARCH \
124     def call_hello(): \
125         \"call hello\" \
126         hello() \
127     ======= \
128     def call_hello_renamed(): \
129         \"call hello renamed\" \
130         hello_renamed() \
131     >>>>>>> REPLACE \
132     <<<<<<< SEARCH \
133     impl1() \
134     hello() \
135     impl2() \
136     ======= \
137     impl1() \
138     hello_renamed() \
139     impl2() \
140     >>>>>>> REPLACE \
141     ``` \
142     # *SEARCH/REPLACE block* Rules: \
143     Every \"<<<<<<< SEARCH\" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc. \
144     Including multiple unique *SEARCH/REPLACE* blocks if needed. \
145     Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change. \
146     Keep *SEARCH/REPLACE* blocks concise. \
147     Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. \
148     Include just the changing lines, and a few surrounding lines (0-3 lines) if needed for uniqueness. \
149     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. \
150     Preserve leading spaces and indentations in both SEARCH and REPLACE blocks.";
151
152const CONTEXT_SAVE_DESCRIPTION: &str =
153    "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
154     - Provide random 3 word unqiue id or whatever user provided. \
155     - Leave project path as empty string if no project path";
156
157static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
158static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
159
160fn winx_tools() -> Vec<Tool> {
161    WINX_TOOLS.get_or_init(build_winx_tools).clone()
162}
163
164fn build_winx_tools() -> Vec<Tool> {
165    vec![
166        mcp_tool::<Initialize>(
167            "Initialize",
168            INITIALIZE_DESCRIPTION,
169            ToolAnnotations::new().read_only(true).open_world(false),
170        ),
171        mcp_tool::<BashCommand>(
172            "BashCommand",
173            BASH_COMMAND_DESCRIPTION,
174            ToolAnnotations::new().destructive(true).open_world(true),
175        ),
176        mcp_tool::<ReadFiles>(
177            "ReadFiles",
178            READ_FILES_DESCRIPTION,
179            ToolAnnotations::new().read_only(true).open_world(false),
180        ),
181        mcp_tool::<FileWriteOrEdit>(
182            "FileWriteOrEdit",
183            FILE_WRITE_OR_EDIT_DESCRIPTION,
184            ToolAnnotations::new().destructive(true).open_world(false),
185        ),
186        mcp_tool::<ContextSave>(
187            "ContextSave",
188            CONTEXT_SAVE_DESCRIPTION,
189            ToolAnnotations::new().destructive(false).open_world(false),
190        ),
191        mcp_tool::<ReadImage>(
192            "ReadImage",
193            "Read an image from the shell.",
194            ToolAnnotations::new().read_only(true).open_world(false),
195        ),
196    ]
197}
198
199fn winx_prompts() -> Vec<Prompt> {
200    WINX_PROMPTS
201        .get_or_init(|| {
202            vec![Prompt::new(
203                "KnowledgeTransfer",
204                Some("Summarize current Winx state, workspace context, and handoff notes."),
205                None,
206            )]
207        })
208        .clone()
209}
210
211fn append_command_section<const N: usize>(
212    output: &mut String,
213    title: &str,
214    cwd: &Path,
215    args: [&str; N],
216) {
217    let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
218        return;
219    };
220    if !command_output.status.success() {
221        return;
222    }
223
224    let content = String::from_utf8_lossy(&command_output.stdout);
225    if content.trim().is_empty() {
226        return;
227    }
228
229    let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
230}
231
232/// Winx service with shared bash state
233#[derive(Clone)]
234pub struct WinxService {
235    /// Shared state for the bash shell environment (async-safe)
236    pub bash_state: SharedBashState,
237    /// Version information for the service
238    pub version: String,
239}
240
241impl Default for WinxService {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247impl WinxService {
248    /// Create a new `WinxService` instance
249    pub fn new() -> Self {
250        info!("Creating new WinxService instance");
251        Self {
252            bash_state: Arc::new(Mutex::new(None)),
253            version: env!("CARGO_PKG_VERSION").to_string(),
254        }
255    }
256}
257
258/// `ServerHandler` implementation
259impl ServerHandler for WinxService {
260    fn get_info(&self) -> ServerInfo {
261        ServerInfo::new(
262            ServerCapabilities::builder()
263                .enable_tools()
264                .enable_resources()
265                .enable_prompts()
266                .build(),
267        )
268        .with_server_info(
269            Implementation::new("winx-mcp-server", self.version.clone())
270                .with_title("Winx High-Performance MCP"),
271        )
272        .with_protocol_version(ProtocolVersion::V_2024_11_05)
273        .with_instructions(
274                "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
275        )
276    }
277
278    async fn list_tools(
279        &self,
280        _request: Option<PaginatedRequestParams>,
281        _context: RequestContext<RoleServer>,
282    ) -> Result<ListToolsResult, McpError> {
283        Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
284    }
285
286    async fn list_resources(
287        &self,
288        _param: Option<PaginatedRequestParams>,
289        _context: RequestContext<RoleServer>,
290    ) -> Result<ListResourcesResult, McpError> {
291        Ok(ListResourcesResult {
292            resources: vec![Annotated {
293                raw: RawResource {
294                    uri: "file://readme".into(),
295                    name: "README".into(),
296                    description: Some("Project README documentation".into()),
297                    mime_type: Some("text/markdown".into()),
298                    size: None,
299                    title: None,
300                    icons: None,
301                    meta: None,
302                },
303                annotations: None,
304            }],
305            next_cursor: None,
306            meta: None,
307        })
308    }
309
310    async fn list_prompts(
311        &self,
312        _request: Option<PaginatedRequestParams>,
313        _context: RequestContext<RoleServer>,
314    ) -> Result<ListPromptsResult, McpError> {
315        Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
316    }
317
318    async fn get_prompt(
319        &self,
320        request: GetPromptRequestParams,
321        _context: RequestContext<RoleServer>,
322    ) -> Result<GetPromptResult, McpError> {
323        if request.name != "KnowledgeTransfer" {
324            return Err(McpError::invalid_request(
325                format!("Unknown prompt: {}", request.name),
326                None,
327            ));
328        }
329
330        let text = self.knowledge_transfer_prompt_text().await;
331
332        Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
333            .with_description("Knowledge transfer handoff prompt"))
334    }
335
336    async fn read_resource(
337        &self,
338        param: ReadResourceRequestParams,
339        _context: RequestContext<RoleServer>,
340    ) -> Result<ReadResourceResult, McpError> {
341        let content = match param.uri.as_ref() {
342            "file://readme" => match tokio::fs::read_to_string("README.md").await {
343                Ok(content) => vec![ResourceContents::text(content, param.uri.clone())],
344                Err(_) => vec![ResourceContents::text(
345                    "README.md not found".to_string(),
346                    param.uri.clone(),
347                )],
348            },
349            _ => {
350                return Err(McpError::invalid_request(
351                    format!("Unknown resource URI: {}", param.uri),
352                    None,
353                ));
354            }
355        };
356
357        Ok(ReadResourceResult::new(content))
358    }
359
360    async fn call_tool(
361        &self,
362        param: CallToolRequestParams,
363        _context: RequestContext<RoleServer>,
364    ) -> Result<CallToolResult, McpError> {
365        let args_value = param.arguments.map(Value::Object);
366        match param.name.as_ref() {
367            "Initialize" => self.handle_initialize(args_value).await,
368            "BashCommand" => self.handle_bash_command(args_value).await,
369            "ReadFiles" => self.handle_read_files(args_value).await,
370            "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
371            "ContextSave" => self.handle_context_save(args_value).await,
372            "ReadImage" => self.handle_read_image(args_value).await,
373            _ => Err(McpError::invalid_request(format!("Unknown tool: {}", param.name), None)),
374        }
375    }
376}
377
378impl WinxService {
379    async fn knowledge_transfer_prompt_text(&self) -> String {
380        let mut text = String::from(
381            "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",
382        );
383
384        let state_snapshot = {
385            let guard = self.bash_state.lock().await;
386            guard.as_ref().map(|state| {
387                let whitelist = state
388                    .whitelist_for_overwrite
389                    .iter()
390                    .take(12)
391                    .map(|(path, data)| {
392                        format!(
393                            "- {} ({:.1}% read, {} lines)",
394                            path,
395                            data.get_percentage_read(),
396                            data.total_lines
397                        )
398                    })
399                    .collect::<Vec<_>>();
400                (
401                    state.current_thread_id.clone(),
402                    state.workspace_root.clone(),
403                    state.cwd.clone(),
404                    state.mode.to_string(),
405                    whitelist,
406                    state.whitelist_for_overwrite.len(),
407                )
408            })
409        };
410
411        let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
412            state_snapshot
413        else {
414            text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
415            return text;
416        };
417
418        let _ = writeln!(
419            text,
420            "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
421            workspace_root.display(),
422            cwd.display()
423        );
424
425        if !whitelist.is_empty() {
426            text.push_str("\n# Recently readable files\n");
427            text.push_str(&whitelist.join("\n"));
428            text.push('\n');
429        }
430
431        let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
432        if !active_files.is_empty() {
433            text.push_str("\n# Active files by Winx usage\n");
434            for file in active_files.iter().take(12) {
435                let _ = writeln!(text, "- {file}");
436            }
437        }
438
439        if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
440            let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
441            let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
442        }
443
444        append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
445        append_command_section(
446            &mut text,
447            "Git diff stat",
448            &workspace_root,
449            ["diff", "--stat", "HEAD"],
450        );
451
452        text.push_str(
453            "\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",
454        );
455
456        text
457    }
458
459    async fn persist_state(&self) {
460        let guard = self.bash_state.lock().await;
461        if let Some(state) = guard.as_ref() {
462            if let Err(error) = state.save_state_to_disk() {
463                warn!("Failed to persist bash state: {}", error);
464            }
465        }
466    }
467
468    async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
469        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
470        let initialize: Initialize = serde_json::from_value(args).map_err(|e| {
471            McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
472        })?;
473
474        match crate::tools::initialize::handle_tool_call(&self.bash_state, initialize).await {
475            Ok(result) => {
476                self.persist_state().await;
477                Ok(CallToolResult::success(vec![Content::text(result)]))
478            }
479            Err(e) => Err(McpError::internal_error(format!("Initialize failed: {e}"), None)),
480        }
481    }
482
483    async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
484        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
485        let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
486            McpError::invalid_request(
487                format!(
488                    "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
489                ),
490                None,
491            )
492        })?;
493
494        match crate::tools::bash_command::handle_tool_call(&self.bash_state, bash_command).await {
495            Ok(output) => {
496                self.persist_state().await;
497                Ok(CallToolResult::success(vec![Content::text(output)]))
498            }
499            Err(e) => Err(McpError::internal_error(format!("BashCommand failed: {e}"), None)),
500        }
501    }
502
503    async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
504        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
505        let read_files: ReadFiles = serde_json::from_value(args).map_err(|e| {
506            McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
507        })?;
508
509        match crate::tools::read_files::handle_tool_call(&self.bash_state, read_files).await {
510            Ok(result) => {
511                self.persist_state().await;
512                Ok(CallToolResult::success(vec![Content::text(result)]))
513            }
514            Err(e) => Err(McpError::internal_error(format!("ReadFiles failed: {e}"), None)),
515        }
516    }
517
518    async fn handle_file_write_or_edit(
519        &self,
520        args: Option<Value>,
521    ) -> Result<CallToolResult, McpError> {
522        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
523        let file_write_or_edit: FileWriteOrEdit = serde_json::from_value(args).map_err(|e| {
524            McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
525        })?;
526
527        match crate::tools::file_write_or_edit::handle_tool_call(
528            &self.bash_state,
529            file_write_or_edit,
530        )
531        .await
532        {
533            Ok(result) => {
534                self.persist_state().await;
535                Ok(CallToolResult::success(vec![Content::text(result)]))
536            }
537            Err(e) => Err(McpError::internal_error(format!("FileWriteOrEdit failed: {e}"), None)),
538        }
539    }
540
541    async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
542        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
543        let context_save: ContextSave = serde_json::from_value(args).map_err(|e| {
544            McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
545        })?;
546
547        match crate::tools::context_save::handle_tool_call(&self.bash_state, context_save).await {
548            Ok(result) => {
549                self.persist_state().await;
550                Ok(CallToolResult::success(vec![Content::text(result)]))
551            }
552            Err(e) => Err(McpError::internal_error(format!("ContextSave failed: {e}"), None)),
553        }
554    }
555
556    async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
557        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
558        let read_image: ReadImage = serde_json::from_value(args).map_err(|e| {
559            McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
560        })?;
561
562        match crate::tools::read_image::handle_tool_call(&self.bash_state, read_image).await {
563            Ok((mime_type, base64_data)) => {
564                let result_text = format!("MIME: {mime_type}\nData: {base64_data}");
565                self.persist_state().await;
566                Ok(CallToolResult::success(vec![Content::text(result_text)]))
567            }
568            Err(e) => Err(McpError::internal_error(format!("ReadImage failed: {e}"), None)),
569        }
570    }
571}
572
573/// Create and start the Winx MCP server
574pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
575    info!("Starting Winx MCP Server");
576    let service = WinxService::new();
577    let server = service.serve(stdio()).await?;
578    server.waiting().await?;
579    Ok(())
580}