1use 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
28pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
30
31fn 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#[derive(Clone)]
234pub struct WinxService {
235 pub bash_state: SharedBashState,
237 pub version: String,
239}
240
241impl Default for WinxService {
242 fn default() -> Self {
243 Self::new()
244 }
245}
246
247impl WinxService {
248 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
258impl 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 tool = param.name.to_string();
366 let args_value = param.arguments.map(Value::Object);
367 let summary = audit_summary(&tool, args_value.as_ref());
371 let started = std::time::Instant::now();
372
373 let result = match tool.as_str() {
374 "Initialize" => self.handle_initialize(args_value).await,
375 "BashCommand" => self.handle_bash_command(args_value).await,
376 "ReadFiles" => self.handle_read_files(args_value).await,
377 "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
378 "ContextSave" => self.handle_context_save(args_value).await,
379 "ReadImage" => self.handle_read_image(args_value).await,
380 _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
381 };
382
383 let ms = started.elapsed().as_millis();
384 match &result {
385 Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
386 Err(error) => warn!(tool = %tool, ms, "tool call error — {summary}: {}", error.message),
387 }
388 result
389 }
390}
391
392fn audit_summary(tool: &str, args: Option<&Value>) -> String {
394 let Some(args) = args else {
395 return "(no args)".to_string();
396 };
397 let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
398 let clip = |text: String| text.chars().take(100).collect::<String>();
399 match tool {
400 "BashCommand" => {
401 let action = args.get("action_json");
402 let cmd = action
403 .and_then(|a| a.get("command"))
404 .and_then(Value::as_str)
405 .or_else(|| args.get("command").and_then(Value::as_str));
406 if let Some(cmd) = cmd {
407 format!("cmd={:?}", clip(cmd.to_string()))
408 } else {
409 let kind =
410 action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
411 format!("action={kind}")
412 }
413 }
414 "FileWriteOrEdit" | "ReadImage" => format!("path={}", s("file_path")),
415 "ReadFiles" => {
416 format!(
417 "files={}",
418 args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
419 )
420 }
421 "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
422 "ContextSave" => format!("id={}", s("id")),
423 _ => String::new(),
424 }
425}
426
427impl WinxService {
428 async fn knowledge_transfer_prompt_text(&self) -> String {
429 let mut text = String::from(
430 "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",
431 );
432
433 let state_snapshot = {
434 let guard = self.bash_state.lock().await;
435 guard.as_ref().map(|state| {
436 let whitelist = state
437 .whitelist_for_overwrite
438 .iter()
439 .take(12)
440 .map(|(path, data)| {
441 format!(
442 "- {} ({:.1}% read, {} lines)",
443 path,
444 data.get_percentage_read(),
445 data.total_lines
446 )
447 })
448 .collect::<Vec<_>>();
449 (
450 state.current_thread_id.clone(),
451 state.workspace_root.clone(),
452 state.cwd.clone(),
453 state.mode.to_string(),
454 whitelist,
455 state.whitelist_for_overwrite.len(),
456 )
457 })
458 };
459
460 let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
461 state_snapshot
462 else {
463 text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
464 return text;
465 };
466
467 let _ = writeln!(
468 text,
469 "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
470 workspace_root.display(),
471 cwd.display()
472 );
473
474 if !whitelist.is_empty() {
475 text.push_str("\n# Recently readable files\n");
476 text.push_str(&whitelist.join("\n"));
477 text.push('\n');
478 }
479
480 let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
481 if !active_files.is_empty() {
482 text.push_str("\n# Active files by Winx usage\n");
483 for file in active_files.iter().take(12) {
484 let _ = writeln!(text, "- {file}");
485 }
486 }
487
488 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
489 let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
490 let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
491 }
492
493 append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
494 append_command_section(
495 &mut text,
496 "Git diff stat",
497 &workspace_root,
498 ["diff", "--stat", "HEAD"],
499 );
500
501 text.push_str(
502 "\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",
503 );
504
505 text
506 }
507
508 async fn persist_state(&self) {
509 let guard = self.bash_state.lock().await;
510 if let Some(state) = guard.as_ref() {
511 if let Err(error) = state.save_state_to_disk() {
512 warn!("Failed to persist bash state: {}", error);
513 }
514 }
515 }
516
517 async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
518 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
519 let initialize: Initialize = serde_json::from_value(args).map_err(|e| {
520 McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
521 })?;
522
523 match crate::tools::initialize::handle_tool_call(&self.bash_state, initialize).await {
524 Ok(result) => {
525 self.persist_state().await;
526 Ok(CallToolResult::success(vec![Content::text(result)]))
527 }
528 Err(e) => Err(McpError::internal_error(format!("Initialize failed: {e}"), None)),
529 }
530 }
531
532 async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
533 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
534 let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
535 McpError::invalid_request(
536 format!(
537 "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
538 ),
539 None,
540 )
541 })?;
542
543 match crate::tools::bash_command::handle_tool_call(&self.bash_state, bash_command).await {
544 Ok(output) => {
545 self.persist_state().await;
546 Ok(CallToolResult::success(vec![Content::text(output)]))
547 }
548 Err(e) => Err(McpError::internal_error(format!("BashCommand failed: {e}"), None)),
549 }
550 }
551
552 async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
553 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
554 let read_files: ReadFiles = serde_json::from_value(args).map_err(|e| {
555 McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
556 })?;
557
558 match crate::tools::read_files::handle_tool_call(&self.bash_state, read_files).await {
559 Ok(result) => {
560 self.persist_state().await;
561 Ok(CallToolResult::success(vec![Content::text(result)]))
562 }
563 Err(e) => Err(McpError::internal_error(format!("ReadFiles failed: {e}"), None)),
564 }
565 }
566
567 async fn handle_file_write_or_edit(
568 &self,
569 args: Option<Value>,
570 ) -> Result<CallToolResult, McpError> {
571 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
572 let file_write_or_edit: FileWriteOrEdit = serde_json::from_value(args).map_err(|e| {
573 McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
574 })?;
575
576 match crate::tools::file_write_or_edit::handle_tool_call(
577 &self.bash_state,
578 file_write_or_edit,
579 )
580 .await
581 {
582 Ok(result) => {
583 self.persist_state().await;
584 Ok(CallToolResult::success(vec![Content::text(result)]))
585 }
586 Err(e) => Err(McpError::internal_error(format!("FileWriteOrEdit failed: {e}"), None)),
587 }
588 }
589
590 async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
591 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
592 let context_save: ContextSave = serde_json::from_value(args).map_err(|e| {
593 McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
594 })?;
595
596 match crate::tools::context_save::handle_tool_call(&self.bash_state, context_save).await {
597 Ok(result) => {
598 self.persist_state().await;
599 Ok(CallToolResult::success(vec![Content::text(result)]))
600 }
601 Err(e) => Err(McpError::internal_error(format!("ContextSave failed: {e}"), None)),
602 }
603 }
604
605 async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
606 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
607 let read_image: ReadImage = serde_json::from_value(args).map_err(|e| {
608 McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
609 })?;
610
611 match crate::tools::read_image::handle_tool_call(&self.bash_state, read_image).await {
612 Ok((mime_type, base64_data)) => {
613 let result_text = format!("MIME: {mime_type}\nData: {base64_data}");
614 self.persist_state().await;
615 Ok(CallToolResult::success(vec![Content::text(result_text)]))
616 }
617 Err(e) => Err(McpError::internal_error(format!("ReadImage failed: {e}"), None)),
618 }
619 }
620}
621
622pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
624 info!("Starting Winx MCP Server");
625 let service = WinxService::new();
626 let server = service.serve(stdio()).await?;
627 server.waiting().await?;
628 Ok(())
629}