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::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::Path;
21use std::process::Command;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::{Arc, OnceLock};
24use std::time::Instant;
25use tokio::sync::Mutex;
26use tracing::{info, warn};
27
28use crate::errors::WinxError;
29use crate::state::bash_state::generate_thread_id;
30use crate::state::BashState;
31use crate::types::{
32 normalize_thread_id, BashCommand, ContextSave, FileWriteOrEdit, Initialize, ReadFiles,
33 ReadImage,
34};
35
36fn to_mcp_error(tool: &str, err: &WinxError) -> McpError {
43 let msg = format!("{tool} failed: {err}");
44 match err {
48 WinxError::BashStateNotInitialized
50 | WinxError::CommandNotAllowed(_)
51 | WinxError::PathSecurityError { .. }
52 | WinxError::ThreadIdMismatch(_)
53 | WinxError::ParameterValidationError { .. }
54 | WinxError::MissingParameterError { .. }
55 | WinxError::NullValueError { .. }
56 | WinxError::ArgumentParseError(_)
57 | WinxError::JsonParseError(_)
58 | WinxError::DeserializationError(_)
59 | WinxError::WorkspacePathError(_)
60 | WinxError::InvalidInput(_)
61 | WinxError::ParseError(_)
62 | WinxError::FileAccessError { .. }
63 | WinxError::RecoverableSuggestionError { .. }
64 | WinxError::SearchReplaceSyntaxError(_)
65 | WinxError::SearchReplaceSyntaxErrorDetailed { .. }
66 | WinxError::SearchBlockNotFound(_)
67 | WinxError::SearchBlockAmbiguous { .. }
68 | WinxError::FileTooLarge { .. }
69 | WinxError::InteractiveCommandDetected { .. }
70 | WinxError::CommandAlreadyRunning { .. } => McpError::invalid_request(msg, None),
71 WinxError::ShellInitializationError(_)
73 | WinxError::BashStateLockError(_)
74 | WinxError::CommandExecutionError(_)
75 | WinxError::SerializationError(_)
76 | WinxError::FileWriteError { .. }
77 | WinxError::DataLoadingError(_)
78 | WinxError::ContextSaveError(_)
79 | WinxError::CommandTimeout { .. }
80 | WinxError::ProcessCleanupError { .. }
81 | WinxError::BufferOverflow { .. }
82 | WinxError::SessionRecoveryError { .. }
83 | WinxError::ResourceAllocationError { .. }
84 | WinxError::IoError(_)
85 | WinxError::ConfigurationError(_)
86 | WinxError::FileError(_) => McpError::internal_error(msg, None),
87 }
88}
89
90pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
92
93fn schema_to_input_schema<T: schemars::JsonSchema>() -> Arc<serde_json::Map<String, Value>> {
95 let schema = schema_for!(T);
96 let mut value = serde_json::to_value(schema).unwrap_or(Value::Object(serde_json::Map::new()));
97 strip_schema_titles(&mut value);
102 match value {
103 Value::Object(map) => Arc::new(map),
104 _ => Arc::new(serde_json::Map::new()),
105 }
106}
107
108fn strip_schema_titles(value: &mut Value) {
115 match value {
116 Value::Object(map) => {
117 const SCHEMA_KEYS: &[&str] = &[
118 "type",
119 "$ref",
120 "properties",
121 "items",
122 "additionalProperties",
123 "enum",
124 "const",
125 "anyOf",
126 "allOf",
127 "oneOf",
128 ];
129 if SCHEMA_KEYS.iter().any(|k| map.contains_key(*k)) {
130 map.remove("title");
131 }
132 for child in map.values_mut() {
133 strip_schema_titles(child);
134 }
135 }
136 Value::Array(items) => {
137 for item in items {
138 strip_schema_titles(item);
139 }
140 }
141 _ => {}
142 }
143}
144
145fn mcp_tool<T: schemars::JsonSchema>(
146 name: &'static str,
147 description: &'static str,
148 annotations: ToolAnnotations,
149) -> Tool {
150 Tool::new(name, description, schema_to_input_schema::<T>()).with_annotations(annotations)
151}
152
153const INITIALIZE_DESCRIPTION: &str =
154 "- Always call this at the start of the conversation before using any of the shell tools from wcgw. \
155 - Use `any_workspace_path` to initialize the shell in the appropriate project directory. \
156 - If the user has mentioned a workspace or project root or any other file or folder use it to set `any_workspace_path`. \
157 - If user has mentioned any files use `initial_files_to_read` to read, use absolute paths only (~ allowed) \
158 - By default use mode \"wcgw\" \
159 - In \"code-writer\" mode, set the commands and globs which user asked to set, otherwise use 'all'. \
160 - Use type=\"first_call\" if it's the first call to this tool. \
161 - Use type=\"user_asked_mode_change\" if in a conversation user has asked to change mode. \
162 - Use type=\"reset_shell\" if in a conversation shell is not working after multiple tries. \
163 - Use type=\"user_asked_change_workspace\" if in a conversation user asked to change workspace";
164
165const BASH_COMMAND_DESCRIPTION: &str =
166 "- Execute a bash command. This is stateful (beware with subsequent calls). \
167 - Accepted payloads include action_json with an explicit type, action_json shorthand such as {\"command\":\"pwd\"}, or top-level shorthand such as {\"command\":\"pwd\"}. \
168 - Status of the command and the current working directory will always be returned at the end. \
169 - The first or the last line might be `(...truncated)` if the output is too long. \
170 - Always run `pwd` if you get any file or directory not found error to make sure you're not lost. \
171 - Do not run bg commands using \"&\", instead use this tool. \
172 - You must not use echo/cat to read/write files, use ReadFiles/FileWriteOrEdit \
173 - In order to check status of previous command, use `status_check` with empty command argument. \
174 - Only command is allowed to run at a time. You need to wait for any previous command to finish before running a new one. \
175 - 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. \
176 - Do not send Ctrl-c before checking for status till 10 minutes or whatever is appropriate for the program to finish. \
177 - Only run long running commands in background. Each background command is run in a new non-reusable shell. \
178 - On running a bg command you'll get a bg command id that you should use to get status or interact. \
179 - Piloting an interactive full-screen TUI (the `claude` CLI, vim, htop, fzf, a REPL)? Run it in the background, then drive it with these two actions: \
180 - `screen` ({\"screen\":true,\"bg_command_id\":\"...\",\"lines\":N,\"diff\":true}) returns a STABLE snapshot of the live terminal screen (cursor moves, redraws, alternate-screen and synchronized-output already applied; ANSI stripped), with the cursor position in the header. Use this to read the current frame — unlike `status_check`, it never stacks redraw generations and never waits. Pass \"diff\":true to get back ONLY the lines that changed since your last `screen` look (large token savings when polling a TUI frame-by-frame; first look or a big change still returns the full frame). \
181 - `wait_for_turn` ({\"wait_for_turn\":true,\"bg_command_id\":\"...\",\"recognizer\":\"auto|claude|codex|antigravity|generic\",\"quiet_ms\":600,\"timeout_seconds\":30}) BLOCKS until the TUI finishes its turn and is ready for input, then returns the stable snapshot plus the detected state (busy / awaiting_input / awaiting_approval). Typical REPL loop: run the app in bg -> wait_for_turn -> send_text(submit:true) -> wait_for_turn -> screen, repeat.";
182
183const READ_FILES_DESCRIPTION: &str =
184 "- Read full file content of one or more files. \
185 - 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. \
186 - Do NOT use this for binary files or images — use ReadImage for images. \
187 - Provide absolute paths only (~ allowed) \
188 - Only if the task requires line numbers understanding: \
189 - 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`";
190
191const FILE_WRITE_OR_EDIT_DESCRIPTION: &str =
192 "- Writes or edits a file based on the percentage of changes. \
193 - Prefer this over writing/editing files with BashCommand (echo/sed/redirects/heredocs). \
194 - For an edit, the file must have been read first with ReadFiles, otherwise the edit is rejected. \
195 - Use absolute path only (~ allowed). \
196 - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change \
197 - 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. \
198 - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks \
199 - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks. \
200 \
201 Instructions for editing files. \
202 # Example \
203 ## Input file \
204 ``` \
205 import numpy as np \
206 from impls import impl1, impl2 \
207 \
208 def hello(): \
209 \"print a greeting\" \
210 print(\"hello\") \
211 \
212 def call_hello(): \
213 \"call hello\" \
214 hello() \
215 print(\"Called\") \
216 impl1() \
217 hello() \
218 impl2() \
219 ``` \
220 ## Edit format on the input file \
221 ``` \
222 <<<<<<< SEARCH \
223 from impls import impl1, impl2 \
224 ======= \
225 from impls import impl1, impl2 \
226 from hello import hello as hello_renamed \
227 >>>>>>> REPLACE \
228 <<<<<<< SEARCH \
229 def hello(): \
230 \"print a greeting\" \
231 print(\"hello\") \
232 ======= \
233 >>>>>>> REPLACE \
234 <<<<<<< SEARCH \
235 def call_hello(): \
236 \"call hello\" \
237 hello() \
238 ======= \
239 def call_hello_renamed(): \
240 \"call hello renamed\" \
241 hello_renamed() \
242 >>>>>>> REPLACE \
243 <<<<<<< SEARCH \
244 impl1() \
245 hello() \
246 impl2() \
247 ======= \
248 impl1() \
249 hello_renamed() \
250 impl2() \
251 >>>>>>> REPLACE \
252 ``` \
253 # *SEARCH/REPLACE block* Rules: \
254 Every \"<<<<<<< SEARCH\" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc. \
255 Including multiple unique *SEARCH/REPLACE* blocks if needed. \
256 Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change. \
257 Keep *SEARCH/REPLACE* blocks concise. \
258 Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. \
259 Include just the changing lines, and a few surrounding lines (0-3 lines) if needed for uniqueness. \
260 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. \
261 Preserve leading spaces and indentations in both SEARCH and REPLACE blocks. \
262 If a short block would match multiple places, anchor it to a line number from ReadFiles instead of padding with context: write the marker as \"<<<<<<< SEARCH @42\" (or a range \"@42-50\") to target that 1-based location. A stale anchor falls back to the normal search, so it never makes a valid edit fail.";
263
264const CONTEXT_SAVE_DESCRIPTION: &str =
265 "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
266 - Provide random 3 word unqiue id or whatever user provided. \
267 - Leave project path as empty string if no project path";
268
269static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
270static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
271
272fn winx_tools() -> Vec<Tool> {
273 WINX_TOOLS.get_or_init(build_winx_tools).clone()
274}
275
276fn build_winx_tools() -> Vec<Tool> {
277 vec![
278 mcp_tool::<Initialize>(
279 "Initialize",
280 INITIALIZE_DESCRIPTION,
281 ToolAnnotations::new().read_only(true).open_world(false),
282 ),
283 mcp_tool::<BashCommand>(
284 "BashCommand",
285 BASH_COMMAND_DESCRIPTION,
286 ToolAnnotations::new().destructive(true).open_world(true),
287 ),
288 mcp_tool::<ReadFiles>(
289 "ReadFiles",
290 READ_FILES_DESCRIPTION,
291 ToolAnnotations::new().read_only(true).open_world(false),
292 ),
293 mcp_tool::<FileWriteOrEdit>(
294 "FileWriteOrEdit",
295 FILE_WRITE_OR_EDIT_DESCRIPTION,
296 ToolAnnotations::new().destructive(true).open_world(false),
297 ),
298 mcp_tool::<ContextSave>(
299 "ContextSave",
300 CONTEXT_SAVE_DESCRIPTION,
301 ToolAnnotations::new().destructive(false).open_world(false),
302 ),
303 mcp_tool::<ReadImage>(
304 "ReadImage",
305 "Read an image from the shell.",
306 ToolAnnotations::new().read_only(true).open_world(false),
307 ),
308 ]
309}
310
311fn winx_prompts() -> Vec<Prompt> {
312 WINX_PROMPTS
313 .get_or_init(|| {
314 vec![Prompt::new(
315 "KnowledgeTransfer",
316 Some("Summarize current Winx state, workspace context, and handoff notes."),
317 None,
318 )]
319 })
320 .clone()
321}
322
323fn append_command_section<const N: usize>(
324 output: &mut String,
325 title: &str,
326 cwd: &Path,
327 args: [&str; N],
328) {
329 let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
330 return;
331 };
332 if !command_output.status.success() {
333 return;
334 }
335
336 let content = String::from_utf8_lossy(&command_output.stdout);
337 if content.trim().is_empty() {
338 return;
339 }
340
341 let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
342}
343
344const MAX_SESSIONS: usize = 32;
348
349#[derive(Default)]
354struct SessionRegistry {
355 slots: HashMap<String, SharedBashState>,
356 last_used: HashMap<String, Instant>,
358 in_flight: HashMap<String, SessionPin>,
362 last_active: Option<String>,
365}
366
367#[derive(Clone)]
374struct SessionPin {
375 count: Arc<AtomicUsize>,
376}
377
378impl Default for SessionPin {
379 fn default() -> Self {
380 Self { count: Arc::new(AtomicUsize::new(0)) }
381 }
382}
383
384impl SessionPin {
385 fn acquire(&self) -> SessionGuard {
387 self.count.fetch_add(1, Ordering::SeqCst);
388 SessionGuard { count: self.count.clone() }
389 }
390
391 fn is_pinned(&self) -> bool {
393 self.count.load(Ordering::SeqCst) > 0
394 }
395}
396
397struct SessionGuard {
403 count: Arc<AtomicUsize>,
404}
405
406impl Drop for SessionGuard {
407 fn drop(&mut self) {
408 self.count.fetch_sub(1, Ordering::SeqCst);
409 }
410}
411
412#[derive(Clone, Copy, PartialEq, Eq, Debug)]
414pub enum SessionIsolation {
415 Lenient,
418 Strict,
428}
429
430#[derive(Clone)]
432pub struct WinxService {
433 sessions: Arc<Mutex<SessionRegistry>>,
434 pub version: String,
436 isolation: SessionIsolation,
438}
439
440impl Default for WinxService {
441 fn default() -> Self {
442 Self::new()
443 }
444}
445
446impl WinxService {
447 pub fn new() -> Self {
450 Self::with_isolation(SessionIsolation::Lenient)
451 }
452
453 pub fn with_isolation(isolation: SessionIsolation) -> Self {
456 info!(?isolation, "Creating new WinxService instance");
457 Self {
458 sessions: Arc::new(Mutex::new(SessionRegistry::default())),
459 version: env!("CARGO_PKG_VERSION").to_string(),
460 isolation,
461 }
462 }
463
464 async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
473 let mut reg = self.sessions.lock().await;
474 let key = if thread_id.is_empty() {
475 match self.isolation {
476 SessionIsolation::Lenient => {
478 reg.last_active.clone().unwrap_or_else(|| "default".to_string())
479 }
480 SessionIsolation::Strict => "anonymous".to_string(),
483 }
484 } else {
485 thread_id.to_string()
486 };
487
488 if !reg.slots.contains_key(&key) && reg.slots.len() >= MAX_SESSIONS {
493 let victim = reg
494 .last_used
495 .iter()
496 .filter(|(k, _)| **k != key)
497 .filter(|(k, _)| reg.in_flight.get(k.as_str()).map_or(true, |p| !p.is_pinned()))
498 .min_by_key(|(_, t)| **t)
499 .map(|(k, _)| k.clone());
500 if let Some(victim) = victim {
501 reg.slots.remove(&victim);
502 reg.last_used.remove(&victim);
503 reg.in_flight.remove(&victim);
504 if reg.last_active.as_deref() == Some(victim.as_str()) {
505 reg.last_active = None;
506 }
507 warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
508 } else {
509 warn!(
510 "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
511 );
512 }
513 }
514
515 let slot =
516 reg.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
517 let pin = reg.in_flight.entry(key.clone()).or_default().clone();
518 reg.last_used.insert(key.clone(), Instant::now());
519 if !thread_id.is_empty() {
520 reg.last_active = Some(key);
521 }
522 (slot, pin.acquire())
523 }
524
525 async fn active_slot(&self) -> Option<SharedBashState> {
528 let reg = self.sessions.lock().await;
529 reg.last_active.as_ref().and_then(|key| reg.slots.get(key).cloned())
530 }
531}
532
533impl ServerHandler for WinxService {
535 fn get_info(&self) -> ServerInfo {
536 ServerInfo::new(
537 ServerCapabilities::builder()
538 .enable_tools()
539 .enable_resources()
540 .enable_prompts()
541 .build(),
542 )
543 .with_server_info(
544 Implementation::new("winx-mcp-server", self.version.clone())
545 .with_title("Winx High-Performance MCP"),
546 )
547 .with_protocol_version(ProtocolVersion::V_2024_11_05)
548 .with_instructions(
549 "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
550 )
551 }
552
553 async fn list_tools(
554 &self,
555 _request: Option<PaginatedRequestParams>,
556 _context: RequestContext<RoleServer>,
557 ) -> Result<ListToolsResult, McpError> {
558 Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
559 }
560
561 async fn list_resources(
562 &self,
563 _param: Option<PaginatedRequestParams>,
564 _context: RequestContext<RoleServer>,
565 ) -> Result<ListResourcesResult, McpError> {
566 Ok(ListResourcesResult {
567 resources: vec![Annotated {
568 raw: RawResource {
569 uri: "file://readme".into(),
570 name: "README".into(),
571 description: Some("Project README documentation".into()),
572 mime_type: Some("text/markdown".into()),
573 size: None,
574 title: None,
575 icons: None,
576 meta: None,
577 },
578 annotations: None,
579 }],
580 next_cursor: None,
581 meta: None,
582 })
583 }
584
585 async fn list_prompts(
586 &self,
587 _request: Option<PaginatedRequestParams>,
588 _context: RequestContext<RoleServer>,
589 ) -> Result<ListPromptsResult, McpError> {
590 Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
591 }
592
593 async fn get_prompt(
594 &self,
595 request: GetPromptRequestParams,
596 _context: RequestContext<RoleServer>,
597 ) -> Result<GetPromptResult, McpError> {
598 if request.name != "KnowledgeTransfer" {
599 return Err(McpError::invalid_request(
600 format!("Unknown prompt: {}", request.name),
601 None,
602 ));
603 }
604
605 let text = self.knowledge_transfer_prompt_text().await;
606
607 Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
608 .with_description("Knowledge transfer handoff prompt"))
609 }
610
611 async fn read_resource(
612 &self,
613 param: ReadResourceRequestParams,
614 _context: RequestContext<RoleServer>,
615 ) -> Result<ReadResourceResult, McpError> {
616 let content = match param.uri.as_ref() {
617 "file://readme" => match tokio::fs::read_to_string("README.md").await {
618 Ok(content) => vec![ResourceContents::text(content, param.uri.clone())],
619 Err(_) => vec![ResourceContents::text(
620 "README.md not found".to_string(),
621 param.uri.clone(),
622 )],
623 },
624 _ => {
625 return Err(McpError::invalid_request(
626 format!("Unknown resource URI: {}", param.uri),
627 None,
628 ));
629 }
630 };
631
632 Ok(ReadResourceResult::new(content))
633 }
634
635 async fn call_tool(
636 &self,
637 param: CallToolRequestParams,
638 _context: RequestContext<RoleServer>,
639 ) -> Result<CallToolResult, McpError> {
640 let tool = param.name.to_string();
641 let args_value = param.arguments.map(Value::Object);
642 let summary = audit_summary(&tool, args_value.as_ref());
646 let started = std::time::Instant::now();
647
648 let result = match tool.as_str() {
649 "Initialize" => self.handle_initialize(args_value).await,
650 "BashCommand" => self.handle_bash_command(args_value).await,
651 "ReadFiles" => self.handle_read_files(args_value).await,
652 "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
653 "ContextSave" => self.handle_context_save(args_value).await,
654 "ReadImage" => self.handle_read_image(args_value).await,
655 _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
656 };
657
658 let ms = started.elapsed().as_millis();
659 match &result {
660 Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
661 Err(error) => warn!(tool = %tool, ms, "tool call error — {summary}: {}", error.message),
662 }
663 result
664 }
665}
666
667fn audit_summary(tool: &str, args: Option<&Value>) -> String {
669 let Some(args) = args else {
670 return "(no args)".to_string();
671 };
672 let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
673 let clip = |text: String| text.chars().take(100).collect::<String>();
674 match tool {
675 "BashCommand" => {
676 let action = args.get("action_json");
677 let cmd = action
678 .and_then(|a| a.get("command"))
679 .and_then(Value::as_str)
680 .or_else(|| args.get("command").and_then(Value::as_str));
681 if let Some(cmd) = cmd {
682 format!("cmd={:?}", clip(cmd.to_string()))
683 } else {
684 let kind =
685 action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
686 format!("action={kind}")
687 }
688 }
689 "FileWriteOrEdit" | "ReadImage" => format!("path={}", s("file_path")),
690 "ReadFiles" => {
691 format!(
692 "files={}",
693 args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
694 )
695 }
696 "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
697 "ContextSave" => format!("id={}", s("id")),
698 _ => String::new(),
699 }
700}
701
702impl WinxService {
703 async fn knowledge_transfer_prompt_text(&self) -> String {
704 let mut text = String::from(
705 "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",
706 );
707
708 let state_snapshot = if let Some(slot) = self.active_slot().await {
709 let guard = slot.lock().await;
710 guard.as_ref().map(|state| {
711 let whitelist = state
712 .whitelist_for_overwrite
713 .iter()
714 .take(12)
715 .map(|(path, data)| {
716 format!(
717 "- {} ({:.1}% read, {} lines)",
718 path,
719 data.get_percentage_read(),
720 data.total_lines
721 )
722 })
723 .collect::<Vec<_>>();
724 (
725 state.current_thread_id.clone(),
726 state.workspace_root.clone(),
727 state.cwd.clone(),
728 state.mode.to_string(),
729 whitelist,
730 state.whitelist_for_overwrite.len(),
731 )
732 })
733 } else {
734 None
735 };
736
737 let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
738 state_snapshot
739 else {
740 text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
741 return text;
742 };
743
744 let _ = writeln!(
745 text,
746 "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
747 workspace_root.display(),
748 cwd.display()
749 );
750
751 if !whitelist.is_empty() {
752 text.push_str("\n# Recently readable files\n");
753 text.push_str(&whitelist.join("\n"));
754 text.push('\n');
755 }
756
757 let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
758 if !active_files.is_empty() {
759 text.push_str("\n# Active files by Winx usage\n");
760 for file in active_files.iter().take(12) {
761 let _ = writeln!(text, "- {file}");
762 }
763 }
764
765 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
766 let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
767 let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
768 }
769
770 append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
771 append_command_section(
772 &mut text,
773 "Git diff stat",
774 &workspace_root,
775 ["diff", "--stat", "HEAD"],
776 );
777
778 let sections = if mode == "architect" {
782 "\n# Sections for the ContextSave description (architect mode)\n\
783 - `# Objective` — project and task objective.\n\
784 - `# All user instructions` — everything the user asked, verbatim.\n\
785 - `# Designed plan` — the plan you designed, in detail.\n\
786 - Provide all relevant file paths so the next agent can resume; err toward more.\n"
787 } else {
788 "\n# Sections for the ContextSave description\n\
789 - `# Objective` — project and task objective.\n\
790 - `# All user instructions` — everything the user asked, verbatim.\n\
791 - `# Current status` — what's already done (not what's left).\n\
792 - `# Pending issues with snippets` — verbatim errors/tracebacks/commands; be verbose.\n\
793 - `# Build and development instructions` — how to build/run/test; leave empty if unknown.\n\
794 - Provide all relevant file paths so the next agent can resume; err toward more.\n"
795 };
796 text.push_str(sections);
797
798 text.push_str(
799 "\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",
800 );
801
802 text
803 }
804
805 async fn persist_state(&self, slot: &SharedBashState) {
806 let guard = slot.lock().await;
807 if let Some(state) = guard.as_ref() {
808 if let Err(error) = state.save_state_to_disk() {
809 warn!("Failed to persist bash state: {}", error);
810 }
811 }
812 }
813
814 fn lenient_from_value<T: serde::de::DeserializeOwned>(
819 args: Value,
820 ) -> Result<T, serde_json::Error> {
821 match serde_json::from_value::<T>(args.clone()) {
822 Ok(value) => Ok(value),
823 Err(first_err) => {
824 let Value::Object(mut map) = args else {
825 return Err(first_err);
826 };
827 let mut changed = false;
828 for value in map.values_mut() {
829 if let Value::String(text) = value {
830 if let Ok(parsed) = serde_json::from_str::<Value>(text) {
831 if parsed.is_object() || parsed.is_array() {
832 *value = parsed;
833 changed = true;
834 }
835 }
836 }
837 }
838 if changed {
839 serde_json::from_value::<T>(Value::Object(map))
840 } else {
841 Err(first_err)
842 }
843 }
844 }
845 }
846
847 async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
848 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
849 let mut initialize: Initialize = Self::lenient_from_value(args).map_err(|e| {
850 McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
851 })?;
852
853 let mut thread_id = normalize_thread_id(&initialize.thread_id);
856 if thread_id.is_empty() {
857 thread_id = generate_thread_id();
858 initialize.thread_id.clone_from(&thread_id);
859 }
860 let (slot, _session_guard) = self.session_for(&thread_id).await;
861
862 match crate::tools::initialize::handle_tool_call(&slot, initialize).await {
863 Ok(result) => {
864 self.persist_state(&slot).await;
865 Ok(CallToolResult::success(vec![Content::text(result)]))
866 }
867 Err(e) => Err(to_mcp_error("Initialize", &e)),
868 }
869 }
870
871 async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
872 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
873 let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
874 McpError::invalid_request(
875 format!(
876 "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
877 ),
878 None,
879 )
880 })?;
881
882 let (slot, _session_guard) =
883 self.session_for(&normalize_thread_id(&bash_command.thread_id)).await;
884 match crate::tools::bash_command::handle_tool_call(&slot, bash_command).await {
885 Ok(output) => {
886 self.persist_state(&slot).await;
887 Ok(CallToolResult::success(vec![Content::text(output)]))
888 }
889 Err(e) => Err(to_mcp_error("BashCommand", &e)),
890 }
891 }
892
893 async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
894 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
895 let read_files: ReadFiles = Self::lenient_from_value(args).map_err(|e| {
896 McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
897 })?;
898
899 let (slot, _session_guard) =
900 self.session_for(&normalize_thread_id(&read_files.thread_id)).await;
901 match crate::tools::read_files::handle_tool_call(&slot, read_files).await {
902 Ok(result) => {
903 self.persist_state(&slot).await;
904 Ok(CallToolResult::success(vec![Content::text(result)]))
905 }
906 Err(e) => Err(to_mcp_error("ReadFiles", &e)),
907 }
908 }
909
910 async fn handle_file_write_or_edit(
911 &self,
912 args: Option<Value>,
913 ) -> Result<CallToolResult, McpError> {
914 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
915 let file_write_or_edit: FileWriteOrEdit = Self::lenient_from_value(args).map_err(|e| {
916 McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
917 })?;
918
919 let (slot, _session_guard) =
920 self.session_for(&normalize_thread_id(&file_write_or_edit.thread_id)).await;
921 match crate::tools::file_write_or_edit::handle_tool_call(&slot, file_write_or_edit).await {
922 Ok(result) => {
923 self.persist_state(&slot).await;
924 Ok(CallToolResult::success(vec![Content::text(result)]))
925 }
926 Err(e) => Err(to_mcp_error("FileWriteOrEdit", &e)),
927 }
928 }
929
930 async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
931 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
932 let context_save: ContextSave = Self::lenient_from_value(args).map_err(|e| {
933 McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
934 })?;
935
936 let (slot, _session_guard) =
937 self.session_for(&normalize_thread_id(&context_save.thread_id)).await;
938 match crate::tools::context_save::handle_tool_call(&slot, context_save).await {
939 Ok(result) => {
940 self.persist_state(&slot).await;
941 Ok(CallToolResult::success(vec![Content::text(result)]))
942 }
943 Err(e) => Err(to_mcp_error("ContextSave", &e)),
944 }
945 }
946
947 async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
948 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
949 let read_image: ReadImage = Self::lenient_from_value(args).map_err(|e| {
950 McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
951 })?;
952
953 let (slot, _session_guard) =
954 self.session_for(&normalize_thread_id(&read_image.thread_id)).await;
955 match crate::tools::read_image::handle_tool_call(&slot, read_image).await {
956 Ok((mime_type, base64_data)) => {
957 self.persist_state(&slot).await;
958 Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
962 }
963 Err(e) => Err(to_mcp_error("ReadImage", &e)),
964 }
965 }
966}
967
968pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
970 info!("Starting Winx MCP Server");
971 let service = WinxService::new();
972 let server = service.serve(stdio()).await?;
973 server.waiting().await?;
974 Ok(())
975}
976
977#[cfg(test)]
978mod session_registry_tests {
979 use super::*;
980
981 #[tokio::test]
982 async fn distinct_threads_get_distinct_sessions() {
983 let svc = WinxService::new();
984 let (a, _) = svc.session_for("thread_a").await;
985 let (b, _) = svc.session_for("thread_b").await;
986 assert!(!Arc::ptr_eq(&a, &b));
989 let (a2, _) = svc.session_for("thread_a").await;
991 assert!(Arc::ptr_eq(&a, &a2));
992 }
993
994 #[tokio::test]
995 async fn empty_thread_id_falls_back_to_last_active() {
996 let svc = WinxService::new();
997 let (_a, _) = svc.session_for("thread_a").await;
998 let (b, _) = svc.session_for("thread_b").await; let (fallback, _) = svc.session_for("").await;
1001 assert!(Arc::ptr_eq(&b, &fallback));
1002 assert!(svc.active_slot().await.is_some());
1003 }
1004
1005 #[tokio::test]
1006 async fn strict_isolation_empty_thread_id_does_not_steal_active_session() {
1007 let svc = WinxService::with_isolation(SessionIsolation::Strict);
1008 let (a, _) = svc.session_for("thread_a").await; let (anon, _) = svc.session_for("").await;
1010 assert!(!Arc::ptr_eq(&a, &anon));
1012 let (anon2, _) = svc.session_for("").await;
1014 assert!(Arc::ptr_eq(&anon, &anon2));
1015 let (b, _) = svc.session_for("thread_b").await;
1017 assert!(!Arc::ptr_eq(&a, &b));
1018 }
1019
1020 #[tokio::test]
1021 async fn lru_eviction_caps_live_sessions() {
1022 let svc = WinxService::new();
1023 for i in 0..(MAX_SESSIONS + 5) {
1024 let (_, _) = svc.session_for(&format!("t{i}")).await;
1025 }
1026 let reg = svc.sessions.lock().await;
1027 assert!(reg.slots.len() <= MAX_SESSIONS, "session count {} over cap", reg.slots.len());
1028 }
1029
1030 #[tokio::test]
1031 async fn in_flight_session_is_not_evicted() {
1032 let svc = WinxService::new();
1033 let (_keep_slot, _keep_guard) = svc.session_for("keep").await;
1035 for i in 0..(MAX_SESSIONS + 10) {
1038 let (_, _) = svc.session_for(&format!("filler{i}")).await;
1039 }
1040 let reg = svc.sessions.lock().await;
1041 assert!(
1042 reg.slots.contains_key("keep"),
1043 "an in-flight session must survive LRU eviction churn"
1044 );
1045 }
1046}
1047
1048#[cfg(test)]
1049mod schema_tests {
1050 use super::{schema_to_input_schema, strip_schema_titles};
1051 use serde_json::json;
1052
1053 #[test]
1054 fn strips_titles_from_schema_nodes_only() {
1055 let mut v = json!({
1056 "type": "object",
1057 "title": "ShouldGo",
1058 "properties": {
1059 "title": { "type": "string", "title": "InnerGoes" }
1062 }
1063 });
1064 strip_schema_titles(&mut v);
1065 assert!(v.get("title").is_none(), "schema-node title not stripped");
1066 let props = v.get("properties").and_then(serde_json::Value::as_object);
1067 assert!(
1068 props.is_some_and(|p| p.contains_key("title")),
1069 "property key named 'title' must be preserved"
1070 );
1071 assert!(
1072 props.and_then(|p| p.get("title")).and_then(|t| t.get("title")).is_none(),
1073 "inner schema title not stripped"
1074 );
1075 }
1076
1077 #[test]
1078 fn real_tool_schema_carries_no_titles() {
1079 let schema = schema_to_input_schema::<crate::types::Initialize>();
1080 let blob = serde_json::to_string(&*schema).unwrap_or_default();
1081 assert!(!blob.contains("\"title\""), "tool schema still contains titles: {blob}");
1082 }
1083}
1084
1085#[cfg(test)]
1086mod error_mapping_tests {
1087 use super::*;
1088 use rmcp::model::ErrorCode;
1089 use std::path::PathBuf;
1090
1091 fn code_of(err: &WinxError) -> ErrorCode {
1092 to_mcp_error("Tool", err).code
1093 }
1094
1095 #[test]
1096 fn client_caused_errors_map_to_invalid_request() {
1097 assert_eq!(
1099 code_of(&WinxError::RecoverableSuggestionError {
1100 message: "bad arg".into(),
1101 suggestion: "try x".into(),
1102 }),
1103 ErrorCode::INVALID_REQUEST,
1104 );
1105 assert_eq!(
1107 code_of(&WinxError::ParseError("unexpected token".into())),
1108 ErrorCode::INVALID_REQUEST,
1109 );
1110 assert_eq!(
1112 code_of(&WinxError::FileAccessError {
1113 path: PathBuf::from("/nope"),
1114 message: "no such file".into(),
1115 }),
1116 ErrorCode::INVALID_REQUEST,
1117 );
1118 }
1119
1120 #[test]
1121 fn server_caused_errors_stay_internal_error() {
1122 assert_eq!(
1123 code_of(&WinxError::IoError(std::io::Error::other("disk gone"))),
1124 ErrorCode::INTERNAL_ERROR,
1125 );
1126 assert_eq!(
1127 code_of(&WinxError::BashStateLockError("poisoned".into())),
1128 ErrorCode::INTERNAL_ERROR,
1129 );
1130 }
1131}
1132
1133#[cfg(all(test, feature = "loom"))]
1148mod loom_tests {
1149 use loom::sync::atomic::{AtomicUsize, Ordering};
1150 use loom::sync::Arc;
1151
1152 #[derive(Clone)]
1153 struct PinModel {
1154 count: Arc<AtomicUsize>,
1155 }
1156
1157 struct GuardModel {
1158 count: Arc<AtomicUsize>,
1159 }
1160
1161 impl PinModel {
1162 fn new() -> Self {
1163 Self { count: Arc::new(AtomicUsize::new(0)) }
1164 }
1165 fn acquire(&self) -> GuardModel {
1166 self.count.fetch_add(1, Ordering::SeqCst);
1167 GuardModel { count: self.count.clone() }
1168 }
1169 fn is_pinned(&self) -> bool {
1170 self.count.load(Ordering::SeqCst) > 0
1171 }
1172 }
1173
1174 impl Drop for GuardModel {
1175 fn drop(&mut self) {
1176 self.count.fetch_sub(1, Ordering::SeqCst);
1177 }
1178 }
1179
1180 #[test]
1186 fn loom_concurrent_guards_balance_to_zero() {
1187 loom::model(|| {
1188 let pin = PinModel::new();
1189 let p1 = pin.clone();
1190 let p2 = pin.clone();
1191 let h1 = loom::thread::spawn(move || drop(p1.acquire()));
1192 let h2 = loom::thread::spawn(move || drop(p2.acquire()));
1193 assert!(h1.join().is_ok());
1194 assert!(h2.join().is_ok());
1195 assert_eq!(pin.count.load(Ordering::SeqCst), 0, "pin must settle back to 0");
1196 });
1197 }
1198
1199 #[test]
1203 fn loom_live_guard_always_reads_pinned() {
1204 loom::model(|| {
1205 let pin = PinModel::new();
1206 let observer = pin.clone();
1207 let held = pin.acquire();
1208 let worker = pin.clone();
1211 let h = loom::thread::spawn(move || drop(worker.acquire()));
1212 assert!(observer.is_pinned(), "session with a live guard must read pinned");
1213 assert!(h.join().is_ok());
1214 assert!(observer.is_pinned(), "still pinned while the first guard lives");
1215 drop(held);
1216 assert!(!observer.is_pinned(), "all guards gone -> unpinned");
1217 });
1218 }
1219}