1use rand::RngExt;
5use rmcp::{
6 model::{
7 Annotated, CallToolRequestParams, CallToolResult, CancelTaskParams, CancelTaskResult,
8 ClientJsonRpcMessage, ClientRequest, Content, CreateTaskResult, ErrorCode,
9 GetPromptRequestParams, GetPromptResult, GetTaskInfoParams, GetTaskPayloadResult,
10 GetTaskResult, GetTaskResultParams, Implementation, ListPromptsResult, ListResourcesResult,
11 ListTasksResult, ListToolsResult, PaginatedRequestParams, Prompt, PromptMessage,
12 PromptMessageRole, ProtocolVersion, RawResource, ReadResourceRequestParams,
13 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, ServerJsonRpcMessage,
14 Task, TaskStatus, TaskSupport, TasksCapability, Tool, ToolAnnotations, ToolExecution,
15 },
16 service::{NotificationContext, RequestContext, RoleServer},
17 transport::{async_rw::AsyncRwTransport, stdio, Transport},
18 ErrorData as McpError, ServerHandler, ServiceExt,
19};
20use schemars::schema_for;
21use serde_json::Value;
22use std::collections::HashMap;
23use std::fmt::Write as FmtWrite;
24use std::future::Future;
25use std::path::Path;
26use std::process::Command;
27use std::sync::atomic::{AtomicUsize, Ordering};
28use std::sync::{Arc, OnceLock};
29use std::time::{Duration, Instant};
30use tokio::sync::Mutex;
31use tracing::{info, warn};
32
33use crate::errors::WinxError;
34use crate::state::bash_state::generate_thread_id;
35use crate::state::task_state::{requested_ttl, TaskEntry, TaskRegistry};
36use crate::state::BashState;
37use crate::types::{
38 normalize_thread_id, BashCommand, BashCommandAction, CodeMap, CodeMapStructuredOutput,
39 ContextSave, FileWriteOrEdit, Initialize, InitializeType, ModeName, MultiFileEdit, ReadFiles,
40 ReadImage, UndoEdit,
41};
42
43fn to_mcp_error(tool: &str, err: &WinxError) -> McpError {
50 let msg = format!("{tool} failed: {err}");
51 match err {
55 WinxError::BashStateNotInitialized
57 | WinxError::CommandNotAllowed(_)
58 | WinxError::PathSecurityError { .. }
59 | WinxError::ThreadIdMismatch(_)
60 | WinxError::ParameterValidationError { .. }
61 | WinxError::MissingParameterError { .. }
62 | WinxError::NullValueError { .. }
63 | WinxError::ArgumentParseError(_)
64 | WinxError::JsonParseError(_)
65 | WinxError::DeserializationError(_)
66 | WinxError::WorkspacePathError(_)
67 | WinxError::InvalidInput(_)
68 | WinxError::ParseError(_)
69 | WinxError::FileAccessError { .. }
70 | WinxError::RecoverableSuggestionError { .. }
71 | WinxError::SearchReplaceSyntaxError(_)
72 | WinxError::SearchReplaceSyntaxErrorDetailed { .. }
73 | WinxError::SearchBlockNotFound(_)
74 | WinxError::SearchBlockAmbiguous { .. }
75 | WinxError::FileTooLarge { .. }
76 | WinxError::InteractiveCommandDetected { .. }
77 | WinxError::CommandAlreadyRunning { .. } => McpError::invalid_request(msg, None),
78 WinxError::ShellInitializationError(_)
80 | WinxError::BashStateLockError(_)
81 | WinxError::CommandExecutionError(_)
82 | WinxError::SerializationError(_)
83 | WinxError::FileWriteError { .. }
84 | WinxError::DataLoadingError(_)
85 | WinxError::ContextSaveError(_)
86 | WinxError::CommandTimeout { .. }
87 | WinxError::ProcessCleanupError { .. }
88 | WinxError::BufferOverflow { .. }
89 | WinxError::SessionRecoveryError { .. }
90 | WinxError::ResourceAllocationError { .. }
91 | WinxError::IoError(_)
92 | WinxError::ConfigurationError(_)
93 | WinxError::FileError(_) => McpError::internal_error(msg, None),
94 }
95}
96
97pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
99
100struct LegacyDiscoveryFallback<T> {
105 inner: T,
106}
107
108impl<T> LegacyDiscoveryFallback<T> {
109 fn new(inner: T) -> Self {
110 Self { inner }
111 }
112}
113
114impl<T> Transport<RoleServer> for LegacyDiscoveryFallback<T>
115where
116 T: Transport<RoleServer>,
117{
118 type Error = T::Error;
119
120 fn send(
121 &mut self,
122 item: ServerJsonRpcMessage,
123 ) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
124 self.inner.send(item)
125 }
126
127 async fn receive(&mut self) -> Option<ClientJsonRpcMessage> {
128 loop {
129 let message = self.inner.receive().await?;
130 let discovery_id = match &message {
131 ClientJsonRpcMessage::Request(request)
132 if matches!(
133 &request.request,
134 ClientRequest::CustomRequest(custom)
135 if custom.method == "server/discover"
136 ) =>
137 {
138 Some(request.id.clone())
139 }
140 _ => None,
141 };
142
143 let Some(id) = discovery_id else {
144 return Some(message);
145 };
146
147 let error = McpError::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None);
148 if let Err(error) = self.inner.send(ServerJsonRpcMessage::error(error, Some(id))).await
149 {
150 warn!(%error, "failed to answer server/discover compatibility probe");
151 return None;
152 }
153 }
154 }
155
156 fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
157 self.inner.close()
158 }
159}
160
161fn schema_to_input_schema<T: schemars::JsonSchema>() -> Arc<serde_json::Map<String, Value>> {
163 let schema = schema_for!(T);
164 let mut value = serde_json::to_value(schema).unwrap_or(Value::Object(serde_json::Map::new()));
165 strip_schema_titles(&mut value);
170 strip_unsupported_schema_formats(&mut value);
171 match value {
172 Value::Object(map) => Arc::new(map),
173 _ => Arc::new(serde_json::Map::new()),
174 }
175}
176
177fn strip_unsupported_schema_formats(value: &mut Value) {
180 match value {
181 Value::Object(map) => {
182 if map.get("format").and_then(Value::as_str) == Some("uint") {
183 map.remove("format");
184 }
185 for child in map.values_mut() {
186 strip_unsupported_schema_formats(child);
187 }
188 }
189 Value::Array(items) => {
190 for item in items {
191 strip_unsupported_schema_formats(item);
192 }
193 }
194 _ => {}
195 }
196}
197
198fn strip_schema_titles(value: &mut Value) {
205 match value {
206 Value::Object(map) => {
207 const SCHEMA_KEYS: &[&str] = &[
208 "type",
209 "$ref",
210 "properties",
211 "items",
212 "additionalProperties",
213 "enum",
214 "const",
215 "anyOf",
216 "allOf",
217 "oneOf",
218 ];
219 if SCHEMA_KEYS.iter().any(|k| map.contains_key(*k)) {
220 map.remove("title");
221 }
222 for child in map.values_mut() {
223 strip_schema_titles(child);
224 }
225 }
226 Value::Array(items) => {
227 for item in items {
228 strip_schema_titles(item);
229 }
230 }
231 _ => {}
232 }
233}
234
235fn mcp_tool<T: schemars::JsonSchema>(
236 name: &'static str,
237 description: &'static str,
238 annotations: ToolAnnotations,
239) -> Tool {
240 Tool::new(name, description, schema_to_input_schema::<T>()).with_annotations(annotations)
241}
242
243fn with_output_schema<T: schemars::JsonSchema>(mut tool: Tool) -> Tool {
244 tool.output_schema = Some(schema_to_input_schema::<T>());
245 tool
246}
247
248const INITIALIZE_DESCRIPTION: &str =
249 "- Call this at the start of the conversation before using shell tools, unless a local MCP client supplied Roots and Winx initialized that workspace automatically. \
250 - Use `any_workspace_path` to initialize the shell in the appropriate project directory. \
251 - If the user has mentioned a workspace or project root or any other file or folder use it to set `any_workspace_path`. \
252 - If user has mentioned any files use `initial_files_to_read` to read, use absolute paths only (~ allowed) \
253 - By default use mode \"wcgw\" \
254 - In \"code-writer\" mode, set the commands and globs which user asked to set, otherwise use 'all'. \
255 - Use type=\"first_call\" if it's the first call to this tool. \
256 - Use type=\"user_asked_mode_change\" if in a conversation user has asked to change mode. \
257 - Use type=\"reset_shell\" if in a conversation shell is not working after multiple tries. \
258 - Use type=\"user_asked_change_workspace\" if in a conversation user asked to change workspace";
259
260const BASH_COMMAND_DESCRIPTION: &str =
261 "- Execute a bash command. This is stateful (beware with subsequent calls). \
262 - Accepted payloads include action_json with an explicit type, action_json shorthand such as {\"command\":\"pwd\"}, or top-level shorthand such as {\"command\":\"pwd\"}. \
263 - Status of the command and the current working directory will always be returned at the end. \
264 - The first or the last line might be `(...truncated)` if the output is too long. \
265 - Always run `pwd` if you get any file or directory not found error to make sure you're not lost. \
266 - Do not run bg commands using \"&\", instead use this tool. \
267 - You must not use echo/cat to read/write files, use ReadFiles/FileWriteOrEdit \
268 - In order to check status of previous command, use `status_check` with empty command argument. \
269 - Only command is allowed to run at a time. You need to wait for any previous command to finish before running a new one. \
270 - 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. \
271 - Do not send Ctrl-c before checking for status till 10 minutes or whatever is appropriate for the program to finish. \
272 - Only run long running commands in background. Each background command is run in a new non-reusable shell. \
273 - On running a bg command you'll get a bg command id that you should use to get status or interact. \
274 - MCP Tasks are also supported for a single foreground `command`: send the protocol-level `task` object and an explicit `thread_id`; poll with tasks/get and fetch the final CallToolResult with tasks/result. Do not combine an MCP Task with `is_background=true`. \
275 - 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: \
276 - `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). \
277 - `wait_for_turn` ({\"wait_for_turn\":true,\"bg_command_id\":\"...\",\"recognizer\":\"auto|claude|codex|antigravity|generic\",\"quiet_ms\":600,\"timeout_seconds\":30}) waits for the TUI's turn and returns the stable snapshot plus the detected state (busy / awaiting_input / awaiting_approval). By default it returns as soon as it confirms the app is actively working (state=busy) so a long-running child never pins you for the whole timeout — poll again to keep watching; pass \"wait_through_busy\":true to instead block through busy until it is ready for input (or the timeout fires). Typical REPL loop: run the app in bg -> wait_for_turn until awaiting_input -> send_text(submit:true) -> wait_for_turn -> screen, repeat.";
278
279const READ_FILES_DESCRIPTION: &str =
280 "- Read full file content of one or more files. \
281 - 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. \
282 - Do NOT use this for binary files or images — use ReadImage for images. \
283 - Provide absolute paths only (~ allowed) \
284 - Only if the task requires line numbers understanding: \
285 - 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`";
286
287const FILE_WRITE_OR_EDIT_DESCRIPTION: &str =
288 "- Writes or edits a file based on the percentage of changes. \
289 - Prefer this over writing/editing files with BashCommand (echo/sed/redirects/heredocs). \
290 - For an edit, the file must have been read first with ReadFiles, otherwise the edit is rejected. \
291 - Use absolute path only (~ allowed). \
292 - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change \
293 - 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. \
294 - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks \
295 - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks. \
296 \
297 Instructions for editing files. \
298 # Example \
299 ## Input file \
300 ``` \
301 import numpy as np \
302 from impls import impl1, impl2 \
303 \
304 def hello(): \
305 \"print a greeting\" \
306 print(\"hello\") \
307 \
308 def call_hello(): \
309 \"call hello\" \
310 hello() \
311 print(\"Called\") \
312 impl1() \
313 hello() \
314 impl2() \
315 ``` \
316 ## Edit format on the input file \
317 ``` \
318 <<<<<<< SEARCH \
319 from impls import impl1, impl2 \
320 ======= \
321 from impls import impl1, impl2 \
322 from hello import hello as hello_renamed \
323 >>>>>>> REPLACE \
324 <<<<<<< SEARCH \
325 def hello(): \
326 \"print a greeting\" \
327 print(\"hello\") \
328 ======= \
329 >>>>>>> REPLACE \
330 <<<<<<< SEARCH \
331 def call_hello(): \
332 \"call hello\" \
333 hello() \
334 ======= \
335 def call_hello_renamed(): \
336 \"call hello renamed\" \
337 hello_renamed() \
338 >>>>>>> REPLACE \
339 <<<<<<< SEARCH \
340 impl1() \
341 hello() \
342 impl2() \
343 ======= \
344 impl1() \
345 hello_renamed() \
346 impl2() \
347 >>>>>>> REPLACE \
348 ``` \
349 # *SEARCH/REPLACE block* Rules: \
350 Every \"<<<<<<< SEARCH\" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc. \
351 Including multiple unique *SEARCH/REPLACE* blocks if needed. \
352 Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change. \
353 Keep *SEARCH/REPLACE* blocks concise. \
354 Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. \
355 Include just the changing lines, and a few surrounding lines (0-3 lines) if needed for uniqueness. \
356 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. \
357 Preserve leading spaces and indentations in both SEARCH and REPLACE blocks. \
358 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.";
359
360const MULTI_FILE_EDIT_DESCRIPTION: &str =
361 "- Edits SEVERAL files together, all-or-nothing. Use this over multiple FileWriteOrEdit calls when a change spans files and a partial apply would be bad (e.g. rename a symbol across files). \
362 - Every file's edit is validated and computed in memory FIRST; only if ALL succeed is anything written, so a SEARCH that fails to match in the last file leaves the earlier files untouched. \
363 - Each entry has the same fields as FileWriteOrEdit: file_path (absolute, ~ allowed), percentage_to_change, and text_or_search_replace_blocks. Each file must have been read with ReadFiles first. \
364 - Provide 2+ files; for a single file use FileWriteOrEdit. Do not list the same file twice. \
365 - If a write fails mid-batch (rare: disk/permissions), it stops and reports which files were already written; those are not rolled back.";
366
367const UNDO_EDIT_DESCRIPTION: &str =
368 "- Reverts a file to the content it had before the last FileWriteOrEdit/MultiFileEdit you made to it THIS session. \
369 - Use this to back out a wrong edit instead of re-typing the old content. \
370 - Per-file: call it again on the same file to walk further back through its edits (the last ~10 edits per session are kept, in memory only). \
371 - Refused if the file changed on disk since your edit (so an undo never discards newer changes), and a brand-new file's creation cannot be undone (no prior content) - use BashCommand rm for that. \
372 - Provide file_path (absolute, ~ allowed).";
373
374const CONTEXT_SAVE_DESCRIPTION: &str =
375 "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
376 - Provide random 3 word unqiue id or whatever user provided. \
377 - Leave project path as empty string if no project path";
378
379const CODE_MAP_DESCRIPTION: &str =
380 "- Navigate code structure via tree-sitter - the semantic layer plain grep/rg can't give you. Pick an `operation`: \
381 - operation=\"outline\": map symbols (functions, types, methods, classes, ...). `path` to a FILE returns that file's definitions; `path` to a DIRECTORY (or empty = the whole workspace) returns a relevance-ranked, token-budgeted symbol map across files. Use it instead of reading whole files just to learn their shape. \
382 - operation=\"references\": find where a symbol is defined and referenced (called/used), by name. `name` is required (exact identifier). Counts only real symbol occurrences, never matches inside strings or comments. Output lists definitions first, then references, as `def|ref file:line kind name`. \
383 - Scope either operation with `path` (file or directory; empty = whole workspace); cap with `max_results`. gitignore-aware, workspace-confined, works in every mode. \
384 - 11 languages (rust, js/ts, go, c, c++, java, ruby, c#, php, lua); other files return no symbols. Note: C/C++ grammars tag definitions only, so references reads 0 for `.c`/`.h`/`.cpp`. \
385 - For plain-text/regex search or file discovery, use rg / grep / fd / find via BashCommand.";
386
387static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
388static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
389
390fn winx_tools() -> Vec<Tool> {
391 WINX_TOOLS.get_or_init(build_winx_tools).clone()
392}
393
394fn build_winx_tools() -> Vec<Tool> {
395 vec![
396 mcp_tool::<Initialize>(
397 "Initialize",
398 INITIALIZE_DESCRIPTION,
399 ToolAnnotations::new().read_only(true).open_world(false),
400 ),
401 mcp_tool::<BashCommand>(
402 "BashCommand",
403 BASH_COMMAND_DESCRIPTION,
404 ToolAnnotations::new().destructive(true).open_world(true),
405 )
406 .with_execution(ToolExecution::new().with_task_support(TaskSupport::Optional)),
407 mcp_tool::<ReadFiles>(
408 "ReadFiles",
409 READ_FILES_DESCRIPTION,
410 ToolAnnotations::new().read_only(true).open_world(false),
411 ),
412 mcp_tool::<FileWriteOrEdit>(
413 "FileWriteOrEdit",
414 FILE_WRITE_OR_EDIT_DESCRIPTION,
415 ToolAnnotations::new().destructive(true).open_world(false),
416 ),
417 mcp_tool::<MultiFileEdit>(
418 "MultiFileEdit",
419 MULTI_FILE_EDIT_DESCRIPTION,
420 ToolAnnotations::new().destructive(true).open_world(false),
421 ),
422 mcp_tool::<UndoEdit>(
423 "UndoEdit",
424 UNDO_EDIT_DESCRIPTION,
425 ToolAnnotations::new().destructive(true).open_world(false),
426 ),
427 mcp_tool::<ContextSave>(
428 "ContextSave",
429 CONTEXT_SAVE_DESCRIPTION,
430 ToolAnnotations::new().destructive(false).open_world(false),
431 ),
432 mcp_tool::<ReadImage>(
433 "ReadImage",
434 "Read an image from the shell.",
435 ToolAnnotations::new().read_only(true).open_world(false),
436 ),
437 with_output_schema::<CodeMapStructuredOutput>(mcp_tool::<CodeMap>(
438 "CodeMap",
439 CODE_MAP_DESCRIPTION,
440 ToolAnnotations::new().read_only(true).open_world(false),
441 )),
442 ]
443}
444
445fn winx_prompts() -> Vec<Prompt> {
446 WINX_PROMPTS
447 .get_or_init(|| {
448 vec![Prompt::new(
449 "KnowledgeTransfer",
450 Some("Summarize current Winx state, workspace context, and handoff notes."),
451 None,
452 )]
453 })
454 .clone()
455}
456
457fn append_command_section<const N: usize>(
458 output: &mut String,
459 title: &str,
460 cwd: &Path,
461 args: [&str; N],
462) {
463 let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
464 return;
465 };
466 if !command_output.status.success() {
467 return;
468 }
469
470 let content = String::from_utf8_lossy(&command_output.stdout);
471 if content.trim().is_empty() {
472 return;
473 }
474
475 let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
476}
477
478const MAX_SESSIONS: usize = 32;
482
483#[derive(Default)]
488struct SessionRegistry {
489 slots: HashMap<String, SharedBashState>,
490 last_used: HashMap<String, Instant>,
492 in_flight: HashMap<String, SessionPin>,
496 last_active: Option<String>,
499}
500
501#[derive(Clone)]
508struct SessionPin {
509 count: Arc<AtomicUsize>,
510}
511
512impl Default for SessionPin {
513 fn default() -> Self {
514 Self { count: Arc::new(AtomicUsize::new(0)) }
515 }
516}
517
518impl SessionPin {
519 fn acquire(&self) -> SessionGuard {
521 self.count.fetch_add(1, Ordering::SeqCst);
522 SessionGuard { count: self.count.clone() }
523 }
524
525 fn is_pinned(&self) -> bool {
527 self.count.load(Ordering::SeqCst) > 0
528 }
529}
530
531struct SessionGuard {
537 count: Arc<AtomicUsize>,
538}
539
540impl Drop for SessionGuard {
541 fn drop(&mut self) {
542 self.count.fetch_sub(1, Ordering::SeqCst);
543 }
544}
545
546#[derive(Clone, Copy, PartialEq, Eq, Debug)]
548pub enum SessionIsolation {
549 Lenient,
552 Strict,
562}
563
564#[derive(Clone)]
566pub struct WinxService {
567 sessions: Arc<Mutex<SessionRegistry>>,
568 tasks: Arc<Mutex<TaskRegistry>>,
569 root_bootstrap: Arc<Mutex<()>>,
570 pub version: String,
572 isolation: SessionIsolation,
574}
575
576impl Default for WinxService {
577 fn default() -> Self {
578 Self::new()
579 }
580}
581
582impl WinxService {
583 pub fn new() -> Self {
586 Self::with_isolation(SessionIsolation::Lenient)
587 }
588
589 pub fn with_isolation(isolation: SessionIsolation) -> Self {
592 info!(?isolation, "Creating new WinxService instance");
593 Self {
594 sessions: Arc::new(Mutex::new(SessionRegistry::default())),
595 tasks: Arc::new(Mutex::new(TaskRegistry::default())),
596 root_bootstrap: Arc::new(Mutex::new(())),
597 version: env!("CARGO_PKG_VERSION").to_string(),
598 isolation,
599 }
600 }
601
602 async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
611 let mut reg = self.sessions.lock().await;
612 let key = if thread_id.is_empty() {
613 match self.isolation {
614 SessionIsolation::Lenient => {
616 reg.last_active.clone().unwrap_or_else(|| "default".to_string())
617 }
618 SessionIsolation::Strict => "anonymous".to_string(),
621 }
622 } else {
623 thread_id.to_string()
624 };
625
626 if !reg.slots.contains_key(&key) && reg.slots.len() >= MAX_SESSIONS {
631 let victim = reg
632 .last_used
633 .iter()
634 .filter(|(k, _)| **k != key)
635 .filter(|(k, _)| reg.in_flight.get(k.as_str()).map_or(true, |p| !p.is_pinned()))
636 .min_by_key(|(_, t)| **t)
637 .map(|(k, _)| k.clone());
638 if let Some(victim) = victim {
639 reg.slots.remove(&victim);
640 reg.last_used.remove(&victim);
641 reg.in_flight.remove(&victim);
642 if reg.last_active.as_deref() == Some(victim.as_str()) {
643 reg.last_active = None;
644 }
645 warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
646 } else {
647 warn!(
648 "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
649 );
650 }
651 }
652
653 let slot =
654 reg.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
655 let pin = reg.in_flight.entry(key.clone()).or_default().clone();
656 reg.last_used.insert(key.clone(), Instant::now());
657 if !thread_id.is_empty() {
658 reg.last_active = Some(key);
659 }
660 (slot, pin.acquire())
661 }
662
663 async fn active_slot(&self) -> Option<SharedBashState> {
666 let reg = self.sessions.lock().await;
667 reg.last_active.as_ref().and_then(|key| reg.slots.get(key).cloned())
668 }
669
670 async fn run_bash_task(
674 &self,
675 mut request: CallToolRequestParams,
676 context: RequestContext<RoleServer>,
677 ) -> Result<CallToolResult, McpError> {
678 let args = request
679 .arguments
680 .clone()
681 .ok_or_else(|| McpError::invalid_request("Missing BashCommand arguments", None))?;
682 let mut bash: BashCommand =
683 serde_json::from_value(Value::Object(args)).map_err(|error| {
684 McpError::invalid_request(
685 format!("Invalid BashCommand task parameters: {error}"),
686 None,
687 )
688 })?;
689 let BashCommandAction::Command { is_background, .. } = &bash.action_json else {
690 return Err(McpError::invalid_request(
691 "MCP Tasks support BashCommand's foreground command action only",
692 None,
693 ));
694 };
695 if *is_background {
696 return Err(McpError::invalid_request(
697 "Do not combine an MCP Task with BashCommand is_background=true",
698 None,
699 ));
700 }
701
702 let thread_id = normalize_thread_id(&bash.thread_id);
703 if thread_id.is_empty() {
704 return Err(McpError::invalid_request(
705 "Task-augmented BashCommand requires an explicit thread_id",
706 None,
707 ));
708 }
709 bash.thread_id.clone_from(&thread_id);
710 bash.wait_for_seconds = Some(20.0);
711 request.arguments = Some(task_arguments(&bash)?);
712 request.task = None;
713
714 let mut output = String::new();
715 loop {
716 let result = ServerHandler::call_tool(self, request, context.clone()).await?;
717 let chunk = result
718 .content
719 .iter()
720 .filter_map(|content| content.raw.as_text().map(|text| text.text.as_str()))
721 .collect::<Vec<_>>()
722 .join("\n");
723 append_task_output(&mut output, &chunk);
724 if !chunk.contains("status = still running") {
725 return Ok(CallToolResult::success(vec![Content::text(output)]));
726 }
727
728 let status = BashCommand {
729 action_json: BashCommandAction::StatusCheck {
730 status_check: true,
731 bg_command_id: None,
732 scrollback_lines: None,
733 verbose: false,
734 },
735 wait_for_seconds: Some(20.0),
736 thread_id: thread_id.clone(),
737 };
738 request =
739 CallToolRequestParams::new("BashCommand").with_arguments(task_arguments(&status)?);
740 }
741 }
742
743 async fn interrupt_task_thread(&self, thread_id: &str) {
744 let slot = {
745 let registry = self.sessions.lock().await;
746 registry.slots.get(thread_id).cloned()
747 };
748 let Some(slot) = slot else { return };
749 let shell = {
750 let state = slot.lock().await;
751 state.as_ref().map(|state| state.pty_shell.clone())
752 };
753 let Some(shell) = shell else { return };
754 {
755 let mut guard = shell.lock().await;
756 if let Some(pty) = guard.as_mut() {
757 if let Err(error) = pty.send_interrupt() {
758 warn!(thread_id, %error, "failed to interrupt cancelled MCP task");
759 return;
760 }
761 }
762 }
763
764 let deadline = Instant::now() + Duration::from_secs(3);
768 while Instant::now() < deadline {
769 let recovered = {
770 let mut guard = shell.lock().await;
771 match guard.as_mut() {
772 Some(pty) => pty.poll_output_nonblocking() || !pty.command_running,
773 None => true,
774 }
775 };
776 if recovered {
777 return;
778 }
779 tokio::time::sleep(Duration::from_millis(50)).await;
780 }
781 {
782 let guard = shell.lock().await;
783 if guard.as_ref().is_some_and(|pty| pty.command_running) {
784 warn!(thread_id, "cancelled MCP task did not return to a prompt within 3 seconds");
785 }
786 }
787 }
788
789 async fn has_initialized_session(&self) -> bool {
790 let slots = {
791 let registry = self.sessions.lock().await;
792 registry.slots.values().cloned().collect::<Vec<_>>()
793 };
794 for slot in slots {
795 if slot.lock().await.as_ref().is_some_and(|state| state.initialized) {
796 return true;
797 }
798 }
799 false
800 }
801
802 #[allow(deprecated)]
806 async fn initialize_from_client_roots(&self, context: NotificationContext<RoleServer>) {
807 if self.isolation != SessionIsolation::Lenient {
808 return;
809 }
810 let _bootstrap_guard = self.root_bootstrap.lock().await;
811 if self.has_initialized_session().await {
812 return;
813 }
814 let supports_roots =
815 context.peer.peer_info().is_some_and(|info| info.capabilities.roots.is_some());
816 if !supports_roots {
817 return;
818 }
819 let roots = match context.peer.list_roots().await {
820 Ok(result) => result.roots,
821 Err(error) => {
822 warn!(%error, "client advertised Roots but roots/list failed");
823 return;
824 }
825 };
826 let cwd = std::env::current_dir().ok();
827 let mut paths =
828 roots.iter().filter_map(|root| root_uri_to_path(&root.uri)).collect::<Vec<_>>();
829 let selected = cwd
830 .as_ref()
831 .and_then(|cwd| paths.iter().position(|path| cwd.starts_with(path)))
832 .map(|index| paths.remove(index))
833 .or_else(|| paths.into_iter().next());
834 let Some(workspace) = selected else {
835 warn!("client returned no usable local file Roots; Initialize remains required");
836 return;
837 };
838
839 let initialize = Initialize {
840 init_type: InitializeType::FirstCall,
841 any_workspace_path: workspace.to_string_lossy().into_owned(),
842 initial_files_to_read: Vec::new(),
843 task_id_to_resume: String::new(),
844 mode_name: ModeName::Wcgw,
845 thread_id: generate_thread_id(),
846 code_writer_config: None,
847 };
848 let arguments = match serde_json::to_value(initialize) {
849 Ok(arguments) => arguments,
850 Err(error) => {
851 warn!(%error, "failed to serialize Roots bootstrap request");
852 return;
853 }
854 };
855 match self.handle_initialize(Some(arguments)).await {
856 Ok(_) => info!(workspace = %workspace.display(), "initialized Winx from MCP Roots"),
857 Err(error) => warn!(%error.message, "failed to initialize Winx from MCP Roots"),
858 }
859 }
860}
861
862const MAX_TASK_RUNTIME: Duration = Duration::from_secs(60 * 60);
863const MAX_TASK_OUTPUT_BYTES: usize = 1_000_000;
864
865fn task_arguments<T: serde::Serialize>(
866 value: &T,
867) -> Result<serde_json::Map<String, Value>, McpError> {
868 match serde_json::to_value(value) {
869 Ok(Value::Object(arguments)) => Ok(arguments),
870 Ok(_) => Err(McpError::internal_error("Task arguments were not an object", None)),
871 Err(error) => Err(McpError::internal_error(
872 format!("Failed to serialize task arguments: {error}"),
873 None,
874 )),
875 }
876}
877
878fn append_task_output(output: &mut String, chunk: &str) {
879 if !output.is_empty() && !chunk.is_empty() {
880 output.push('\n');
881 }
882 output.push_str(chunk);
883 if output.len() <= MAX_TASK_OUTPUT_BYTES {
884 return;
885 }
886 let cut = crate::utils::floor_char_boundary(output, output.len() - MAX_TASK_OUTPUT_BYTES);
887 output.replace_range(..cut, "(...earlier task output truncated...)\n");
888}
889
890fn root_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
891 let encoded = uri.strip_prefix("file://")?;
892 let encoded = if let Some(path) = encoded.strip_prefix("localhost/") {
893 format!("/{path}")
894 } else if encoded.starts_with('/') {
895 encoded.to_string()
896 } else {
897 return None;
899 };
900 let decoded = percent_encoding::percent_decode_str(&encoded).decode_utf8().ok()?;
901 let path = std::path::PathBuf::from(decoded.as_ref());
902 path.is_absolute().then_some(path)
903}
904
905impl ServerHandler for WinxService {
907 fn get_info(&self) -> ServerInfo {
908 ServerInfo::new(
909 ServerCapabilities::builder()
910 .enable_tools()
911 .enable_resources()
912 .enable_prompts()
913 .enable_tasks_with(TasksCapability::server_default())
914 .build(),
915 )
916 .with_server_info(
917 Implementation::new("winx-mcp-server", self.version.clone())
918 .with_title("Winx High-Performance MCP"),
919 )
920 .with_protocol_version(ProtocolVersion::V_2025_11_25)
924 .with_instructions(
925 "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
926 )
927 }
928
929 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
930 self.initialize_from_client_roots(context).await;
931 }
932
933 async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) {
934 self.initialize_from_client_roots(context).await;
937 }
938
939 fn get_tool(&self, name: &str) -> Option<Tool> {
940 winx_tools().into_iter().find(|tool| tool.name == name)
941 }
942
943 async fn enqueue_task(
944 &self,
945 request: CallToolRequestParams,
946 context: RequestContext<RoleServer>,
947 ) -> Result<CreateTaskResult, McpError> {
948 if request.name != "BashCommand" {
949 return Err(McpError::invalid_request(
950 "Only BashCommand advertises MCP Task support",
951 None,
952 ));
953 }
954 let args = request
955 .arguments
956 .clone()
957 .ok_or_else(|| McpError::invalid_request("Missing BashCommand arguments", None))?;
958 let bash: BashCommand = serde_json::from_value(Value::Object(args)).map_err(|error| {
959 McpError::invalid_request(format!("Invalid BashCommand task parameters: {error}"), None)
960 })?;
961 let BashCommandAction::Command { is_background, .. } = bash.action_json else {
962 return Err(McpError::invalid_request(
963 "MCP Tasks support BashCommand's foreground command action only",
964 None,
965 ));
966 };
967 if is_background {
968 return Err(McpError::invalid_request(
969 "Do not combine an MCP Task with BashCommand is_background=true",
970 None,
971 ));
972 }
973 let thread_id = normalize_thread_id(&bash.thread_id);
974 if thread_id.is_empty() {
975 return Err(McpError::invalid_request(
976 "Task-augmented BashCommand requires an explicit thread_id",
977 None,
978 ));
979 }
980
981 let task_id = format!("task_{:032x}", rand::rng().random::<u128>());
982 let ttl = requested_ttl(request.task.as_ref());
983 let now = rmcp::task_manager::current_timestamp();
984 let task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now)
985 .with_status_message("Running BashCommand")
986 .with_ttl(ttl)
987 .with_poll_interval(1_000);
988 self.tasks
989 .lock()
990 .await
991 .insert(task_id.clone(), TaskEntry::working(task.clone(), thread_id.clone()))
992 .map_err(|message| McpError::internal_error(message, None))?;
993
994 let service = self.clone();
995 let worker_task_id = task_id.clone();
996 let handle = tokio::spawn(async move {
997 let outcome =
998 tokio::time::timeout(MAX_TASK_RUNTIME, service.run_bash_task(request, context))
999 .await;
1000
1001 let (status, message, result) = match outcome {
1002 Ok(Ok(result)) => match serde_json::to_value(result) {
1003 Ok(result) => (
1004 TaskStatus::Completed,
1005 Some("BashCommand completed".to_string()),
1006 Some(result),
1007 ),
1008 Err(error) => (
1009 TaskStatus::Failed,
1010 Some(format!("Failed to serialize task result: {error}")),
1011 None,
1012 ),
1013 },
1014 Ok(Err(error)) => (
1015 TaskStatus::Failed,
1016 Some(crate::utils::redact::redact(&error.message).into_owned()),
1017 None,
1018 ),
1019 Err(_) => {
1020 service.interrupt_task_thread(&thread_id).await;
1021 (
1022 TaskStatus::Failed,
1023 Some("BashCommand task exceeded the one-hour runtime limit".to_string()),
1024 None,
1025 )
1026 }
1027 };
1028
1029 let mut tasks = service.tasks.lock().await;
1030 if let Some(entry) = tasks.get_mut(&worker_task_id) {
1031 if entry.task.status == TaskStatus::Working {
1034 entry.finish(status, message, result);
1035 }
1036 }
1037 });
1038 let abort_handle = handle.abort_handle();
1039 drop(handle);
1040 if let Some(entry) = self.tasks.lock().await.get_mut(&task_id) {
1041 if entry.task.status == TaskStatus::Working {
1042 entry.abort_handle = Some(abort_handle);
1043 }
1044 }
1045
1046 Ok(CreateTaskResult::new(task))
1047 }
1048
1049 async fn list_tasks(
1050 &self,
1051 _request: Option<PaginatedRequestParams>,
1052 _context: RequestContext<RoleServer>,
1053 ) -> Result<ListTasksResult, McpError> {
1054 let tasks = self.tasks.lock().await.list();
1055 let total = tasks.len() as u64;
1056 let mut result = ListTasksResult::new(tasks);
1057 result.total = Some(total);
1058 Ok(result)
1059 }
1060
1061 async fn get_task_info(
1062 &self,
1063 request: GetTaskInfoParams,
1064 _context: RequestContext<RoleServer>,
1065 ) -> Result<GetTaskResult, McpError> {
1066 let mut tasks = self.tasks.lock().await;
1067 let entry = tasks.get(&request.task_id).ok_or_else(|| {
1068 McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
1069 })?;
1070 Ok(GetTaskResult { meta: None, task: entry.task.clone() })
1071 }
1072
1073 async fn get_task_result(
1074 &self,
1075 request: GetTaskResultParams,
1076 _context: RequestContext<RoleServer>,
1077 ) -> Result<GetTaskPayloadResult, McpError> {
1078 let mut tasks = self.tasks.lock().await;
1079 let entry = tasks.get(&request.task_id).ok_or_else(|| {
1080 McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
1081 })?;
1082 match entry.task.status {
1083 TaskStatus::Completed => entry
1084 .result
1085 .clone()
1086 .map(GetTaskPayloadResult::new)
1087 .ok_or_else(|| McpError::internal_error("Completed task has no result", None)),
1088 TaskStatus::Working | TaskStatus::InputRequired => Err(McpError::invalid_request(
1089 format!("Task {} is not complete", request.task_id),
1090 None,
1091 )),
1092 TaskStatus::Failed | TaskStatus::Cancelled => Err(McpError::invalid_request(
1093 entry
1094 .task
1095 .status_message
1096 .clone()
1097 .unwrap_or_else(|| format!("Task {} did not complete", request.task_id)),
1098 None,
1099 )),
1100 }
1101 }
1102
1103 async fn cancel_task(
1104 &self,
1105 request: CancelTaskParams,
1106 _context: RequestContext<RoleServer>,
1107 ) -> Result<CancelTaskResult, McpError> {
1108 let (abort_handle, thread_id, task) = {
1109 let mut tasks = self.tasks.lock().await;
1110 let entry = tasks.get_mut(&request.task_id).ok_or_else(|| {
1111 McpError::invalid_request(
1112 format!("Unknown or expired task: {}", request.task_id),
1113 None,
1114 )
1115 })?;
1116 if entry.task.status != TaskStatus::Working
1117 && entry.task.status != TaskStatus::InputRequired
1118 {
1119 return Err(McpError::invalid_request(
1120 format!("Task {} is already terminal", request.task_id),
1121 None,
1122 ));
1123 }
1124 let abort_handle = entry.abort_handle.take();
1125 let thread_id = entry.thread_id.clone();
1126 entry.finish(TaskStatus::Cancelled, Some("Cancelled by client".to_string()), None);
1127 (abort_handle, thread_id, entry.task.clone())
1128 };
1129 if let Some(abort_handle) = abort_handle {
1130 abort_handle.abort();
1131 }
1132 self.interrupt_task_thread(&thread_id).await;
1133 Ok(CancelTaskResult { meta: None, task })
1134 }
1135
1136 async fn list_tools(
1137 &self,
1138 _request: Option<PaginatedRequestParams>,
1139 _context: RequestContext<RoleServer>,
1140 ) -> Result<ListToolsResult, McpError> {
1141 Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
1142 }
1143
1144 async fn list_resources(
1145 &self,
1146 _param: Option<PaginatedRequestParams>,
1147 _context: RequestContext<RoleServer>,
1148 ) -> Result<ListResourcesResult, McpError> {
1149 Ok(ListResourcesResult {
1150 resources: vec![Annotated {
1151 raw: RawResource {
1152 uri: "file://readme".into(),
1153 name: "README".into(),
1154 description: Some("Project README documentation".into()),
1155 mime_type: Some("text/markdown".into()),
1156 size: None,
1157 title: None,
1158 icons: None,
1159 meta: None,
1160 },
1161 annotations: None,
1162 }],
1163 next_cursor: None,
1164 meta: None,
1165 })
1166 }
1167
1168 async fn list_prompts(
1169 &self,
1170 _request: Option<PaginatedRequestParams>,
1171 _context: RequestContext<RoleServer>,
1172 ) -> Result<ListPromptsResult, McpError> {
1173 Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
1174 }
1175
1176 async fn get_prompt(
1177 &self,
1178 request: GetPromptRequestParams,
1179 _context: RequestContext<RoleServer>,
1180 ) -> Result<GetPromptResult, McpError> {
1181 if request.name != "KnowledgeTransfer" {
1182 return Err(McpError::invalid_request(
1183 format!("Unknown prompt: {}", request.name),
1184 None,
1185 ));
1186 }
1187
1188 let text =
1191 crate::utils::redact::redact(&self.knowledge_transfer_prompt_text().await).into_owned();
1192
1193 Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
1194 .with_description("Knowledge transfer handoff prompt"))
1195 }
1196
1197 async fn read_resource(
1198 &self,
1199 param: ReadResourceRequestParams,
1200 _context: RequestContext<RoleServer>,
1201 ) -> Result<ReadResourceResult, McpError> {
1202 let content = match param.uri.as_ref() {
1203 "file://readme" => match tokio::fs::read_to_string("README.md").await {
1204 Ok(content) => vec![ResourceContents::text(
1205 crate::utils::redact::redact(&content).into_owned(),
1206 param.uri.clone(),
1207 )],
1208 Err(_) => vec![ResourceContents::text(
1209 "README.md not found".to_string(),
1210 param.uri.clone(),
1211 )],
1212 },
1213 _ => {
1214 return Err(McpError::invalid_request(
1215 format!("Unknown resource URI: {}", param.uri),
1216 None,
1217 ));
1218 }
1219 };
1220
1221 Ok(ReadResourceResult::new(content))
1222 }
1223
1224 async fn call_tool(
1225 &self,
1226 param: CallToolRequestParams,
1227 _context: RequestContext<RoleServer>,
1228 ) -> Result<CallToolResult, McpError> {
1229 let tool = param.name.to_string();
1230 let args_value = param.arguments.map(Value::Object);
1231 let summary =
1237 crate::utils::redact::redact(&audit_summary(&tool, args_value.as_ref())).into_owned();
1238 let started = std::time::Instant::now();
1239
1240 let result = match tool.as_str() {
1241 "Initialize" => self.handle_initialize(args_value).await,
1242 "BashCommand" => self.handle_bash_command(args_value).await,
1243 "ReadFiles" => self.handle_read_files(args_value).await,
1244 "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
1245 "MultiFileEdit" => self.handle_multi_file_edit(args_value).await,
1246 "UndoEdit" => self.handle_undo_edit(args_value).await,
1247 "ContextSave" => self.handle_context_save(args_value).await,
1248 "ReadImage" => self.handle_read_image(args_value).await,
1249 "CodeMap" => self.handle_code_map(args_value).await,
1250 _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
1251 };
1252
1253 let result = match result {
1257 Ok(mut call) => {
1258 redact_result(&mut call);
1259 Ok(call)
1260 }
1261 Err(mut error) => {
1262 error.message = crate::utils::redact::redact(&error.message).into_owned().into();
1266 Err(error)
1267 }
1268 };
1269
1270 let ms = started.elapsed().as_millis();
1271 match &result {
1272 Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
1273 Err(error) => warn!(tool = %tool, ms, "tool call error — {summary}: {}", error.message),
1275 }
1276 result
1277 }
1278}
1279
1280fn audit_summary(tool: &str, args: Option<&Value>) -> String {
1282 let Some(args) = args else {
1283 return "(no args)".to_string();
1284 };
1285 let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
1286 match tool {
1287 "BashCommand" => {
1288 let action = args.get("action_json");
1289 let cmd = action
1290 .and_then(|a| a.get("command"))
1291 .and_then(Value::as_str)
1292 .or_else(|| args.get("command").and_then(Value::as_str));
1293 if let Some(cmd) = cmd {
1294 format!("command bytes={}", cmd.len())
1295 } else {
1296 let kind =
1297 action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
1298 format!("action={kind}")
1299 }
1300 }
1301 "FileWriteOrEdit" | "ReadImage" | "UndoEdit" => format!("path={}", s("file_path")),
1302 "MultiFileEdit" => {
1303 format!("files={}", args.get("files").and_then(Value::as_array).map_or(0, Vec::len))
1304 }
1305 "ReadFiles" => {
1306 format!(
1307 "files={}",
1308 args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
1309 )
1310 }
1311 "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
1312 "ContextSave" => format!("id={}", s("id")),
1313 "CodeMap" => format!("op={} path={} name={}", s("operation"), s("path"), s("name")),
1314 _ => String::new(),
1315 }
1316}
1317
1318fn redact_result(result: &mut CallToolResult) {
1322 use rmcp::model::RawContent;
1323 for content in &mut result.content {
1324 if let RawContent::Text(text) = &mut content.raw {
1325 if let std::borrow::Cow::Owned(scrubbed) = crate::utils::redact::redact(&text.text) {
1326 text.text = scrubbed;
1327 }
1328 }
1329 }
1330 if let Some(structured) = result.structured_content.as_mut() {
1331 crate::utils::redact::redact_json(structured);
1332 }
1333}
1334
1335#[cfg(test)]
1336mod audit_tests {
1337 use super::audit_summary;
1338 use serde_json::json;
1339
1340 #[test]
1341 fn bash_audit_summary_never_contains_command_content() {
1342 let args = json!({
1343 "action_json": {
1344 "type": "command",
1345 "command": "curl -H 'Authorization: secret-value' https://example.invalid"
1346 }
1347 });
1348 let summary = audit_summary("BashCommand", Some(&args));
1349 assert!(summary.starts_with("command bytes="));
1350 assert!(!summary.contains("secret-value"));
1351 assert!(!summary.contains("example.invalid"));
1352 }
1353}
1354
1355impl WinxService {
1356 async fn knowledge_transfer_prompt_text(&self) -> String {
1357 let mut text = String::from(
1358 "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",
1359 );
1360
1361 let state_snapshot = if let Some(slot) = self.active_slot().await {
1362 let guard = slot.lock().await;
1363 guard.as_ref().map(|state| {
1364 let whitelist = state
1365 .whitelist_for_overwrite
1366 .iter()
1367 .take(12)
1368 .map(|(path, data)| {
1369 format!(
1370 "- {} ({:.1}% read, {} lines)",
1371 path,
1372 data.get_percentage_read(),
1373 data.total_lines
1374 )
1375 })
1376 .collect::<Vec<_>>();
1377 (
1378 state.current_thread_id.clone(),
1379 state.workspace_root.clone(),
1380 state.cwd.clone(),
1381 state.mode.to_string(),
1382 whitelist,
1383 state.whitelist_for_overwrite.len(),
1384 )
1385 })
1386 } else {
1387 None
1388 };
1389
1390 let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
1391 state_snapshot
1392 else {
1393 text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
1394 return text;
1395 };
1396
1397 let _ = writeln!(
1398 text,
1399 "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
1400 workspace_root.display(),
1401 cwd.display()
1402 );
1403
1404 if !whitelist.is_empty() {
1405 text.push_str("\n# Recently readable files\n");
1406 text.push_str(&whitelist.join("\n"));
1407 text.push('\n');
1408 }
1409
1410 let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
1411 if !active_files.is_empty() {
1412 text.push_str("\n# Active files by Winx usage\n");
1413 for file in active_files.iter().take(12) {
1414 let _ = writeln!(text, "- {file}");
1415 }
1416 }
1417
1418 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
1419 let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
1420 let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
1421 }
1422
1423 append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
1424 append_command_section(
1425 &mut text,
1426 "Git diff stat",
1427 &workspace_root,
1428 ["diff", "--stat", "HEAD"],
1429 );
1430
1431 let sections = if mode == "architect" {
1435 "\n# Sections for the ContextSave description (architect mode)\n\
1436 - `# Objective` — project and task objective.\n\
1437 - `# All user instructions` — everything the user asked, verbatim.\n\
1438 - `# Designed plan` — the plan you designed, in detail.\n\
1439 - Provide all relevant file paths so the next agent can resume; err toward more.\n"
1440 } else {
1441 "\n# Sections for the ContextSave description\n\
1442 - `# Objective` — project and task objective.\n\
1443 - `# All user instructions` — everything the user asked, verbatim.\n\
1444 - `# Current status` — what's already done (not what's left).\n\
1445 - `# Pending issues with snippets` — verbatim errors/tracebacks/commands; be verbose.\n\
1446 - `# Build and development instructions` — how to build/run/test; leave empty if unknown.\n\
1447 - Provide all relevant file paths so the next agent can resume; err toward more.\n"
1448 };
1449 text.push_str(sections);
1450
1451 text.push_str(
1452 "\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",
1453 );
1454
1455 text
1456 }
1457
1458 async fn persist_state(&self, slot: &SharedBashState) {
1459 let guard = slot.lock().await;
1460 if let Some(state) = guard.as_ref() {
1461 if let Err(error) = state.save_state_to_disk() {
1462 warn!("Failed to persist bash state: {}", error);
1463 }
1464 }
1465 }
1466
1467 fn lenient_from_value<T: serde::de::DeserializeOwned>(
1472 args: Value,
1473 ) -> Result<T, serde_json::Error> {
1474 match serde_json::from_value::<T>(args.clone()) {
1475 Ok(value) => Ok(value),
1476 Err(first_err) => {
1477 let Value::Object(mut map) = args else {
1478 return Err(first_err);
1479 };
1480 let mut changed = false;
1481 for value in map.values_mut() {
1482 if let Value::String(text) = value {
1483 if let Ok(parsed) = serde_json::from_str::<Value>(text) {
1484 if parsed.is_object() || parsed.is_array() {
1485 *value = parsed;
1486 changed = true;
1487 }
1488 }
1489 }
1490 }
1491 if changed {
1492 serde_json::from_value::<T>(Value::Object(map))
1493 } else {
1494 Err(first_err)
1495 }
1496 }
1497 }
1498 }
1499
1500 async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1501 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1502 let mut initialize: Initialize = Self::lenient_from_value(args).map_err(|e| {
1503 McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
1504 })?;
1505
1506 let mut thread_id = normalize_thread_id(&initialize.thread_id);
1509 if thread_id.is_empty() {
1510 thread_id = generate_thread_id();
1511 initialize.thread_id.clone_from(&thread_id);
1512 }
1513 let (slot, _session_guard) = self.session_for(&thread_id).await;
1514
1515 match crate::tools::initialize::handle_tool_call(&slot, initialize).await {
1516 Ok(result) => {
1517 self.persist_state(&slot).await;
1518 Ok(CallToolResult::success(vec![Content::text(result)]))
1519 }
1520 Err(e) => Err(to_mcp_error("Initialize", &e)),
1521 }
1522 }
1523
1524 async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1525 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1526 let mut bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
1527 McpError::invalid_request(
1528 format!(
1529 "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
1530 ),
1531 None,
1532 )
1533 })?;
1534
1535 let requested_thread_id = normalize_thread_id(&bash_command.thread_id);
1536 let (slot, _session_guard) = self.session_for(&requested_thread_id).await;
1537 if requested_thread_id.is_empty() {
1538 if let Some(thread_id) =
1543 slot.lock().await.as_ref().map(|state| state.current_thread_id.clone())
1544 {
1545 bash_command.thread_id = thread_id;
1546 }
1547 }
1548 match crate::tools::bash_command::handle_tool_call(&slot, bash_command).await {
1549 Ok(output) => {
1550 self.persist_state(&slot).await;
1551 Ok(CallToolResult::success(vec![Content::text(output)]))
1552 }
1553 Err(e) => Err(to_mcp_error("BashCommand", &e)),
1554 }
1555 }
1556
1557 async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1558 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1559 let read_files: ReadFiles = Self::lenient_from_value(args).map_err(|e| {
1560 McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
1561 })?;
1562
1563 let (slot, _session_guard) =
1564 self.session_for(&normalize_thread_id(&read_files.thread_id)).await;
1565 match crate::tools::read_files::handle_tool_call(&slot, read_files).await {
1566 Ok(result) => {
1567 self.persist_state(&slot).await;
1568 Ok(CallToolResult::success(vec![Content::text(result)]))
1569 }
1570 Err(e) => Err(to_mcp_error("ReadFiles", &e)),
1571 }
1572 }
1573
1574 async fn handle_file_write_or_edit(
1575 &self,
1576 args: Option<Value>,
1577 ) -> Result<CallToolResult, McpError> {
1578 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1579 let file_write_or_edit: FileWriteOrEdit = Self::lenient_from_value(args).map_err(|e| {
1580 McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
1581 })?;
1582
1583 let (slot, _session_guard) =
1584 self.session_for(&normalize_thread_id(&file_write_or_edit.thread_id)).await;
1585 match crate::tools::file_write_or_edit::handle_tool_call(&slot, file_write_or_edit).await {
1586 Ok(result) => {
1587 self.persist_state(&slot).await;
1588 Ok(CallToolResult::success(vec![Content::text(result)]))
1589 }
1590 Err(e) => Err(to_mcp_error("FileWriteOrEdit", &e)),
1591 }
1592 }
1593
1594 async fn handle_multi_file_edit(
1595 &self,
1596 args: Option<Value>,
1597 ) -> Result<CallToolResult, McpError> {
1598 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1599 let multi: MultiFileEdit = Self::lenient_from_value(args).map_err(|e| {
1600 McpError::invalid_request(format!("Invalid MultiFileEdit parameters: {e}"), None)
1601 })?;
1602
1603 let (slot, _session_guard) = self.session_for(&normalize_thread_id(&multi.thread_id)).await;
1604 match crate::tools::multi_file_edit::handle_tool_call(&slot, multi).await {
1605 Ok(result) => {
1606 self.persist_state(&slot).await;
1607 Ok(CallToolResult::success(vec![Content::text(result)]))
1608 }
1609 Err(e) => Err(to_mcp_error("MultiFileEdit", &e)),
1610 }
1611 }
1612
1613 async fn handle_undo_edit(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1614 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1615 let undo: UndoEdit = Self::lenient_from_value(args).map_err(|e| {
1616 McpError::invalid_request(format!("Invalid UndoEdit parameters: {e}"), None)
1617 })?;
1618
1619 let (slot, _session_guard) = self.session_for(&normalize_thread_id(&undo.thread_id)).await;
1620 match crate::tools::undo_edit::handle_tool_call(&slot, undo).await {
1621 Ok(result) => {
1622 self.persist_state(&slot).await;
1623 Ok(CallToolResult::success(vec![Content::text(result)]))
1624 }
1625 Err(e) => Err(to_mcp_error("UndoEdit", &e)),
1626 }
1627 }
1628
1629 async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1630 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1631 let context_save: ContextSave = Self::lenient_from_value(args).map_err(|e| {
1632 McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
1633 })?;
1634
1635 let (slot, _session_guard) =
1636 self.session_for(&normalize_thread_id(&context_save.thread_id)).await;
1637 match crate::tools::context_save::handle_tool_call(&slot, context_save).await {
1638 Ok(result) => {
1639 self.persist_state(&slot).await;
1640 Ok(CallToolResult::success(vec![Content::text(result)]))
1641 }
1642 Err(e) => Err(to_mcp_error("ContextSave", &e)),
1643 }
1644 }
1645
1646 async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1647 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1648 let read_image: ReadImage = Self::lenient_from_value(args).map_err(|e| {
1649 McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
1650 })?;
1651
1652 let (slot, _session_guard) =
1653 self.session_for(&normalize_thread_id(&read_image.thread_id)).await;
1654 match crate::tools::read_image::handle_tool_call(&slot, read_image).await {
1655 Ok((mime_type, base64_data)) => {
1656 self.persist_state(&slot).await;
1657 Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
1661 }
1662 Err(e) => Err(to_mcp_error("ReadImage", &e)),
1663 }
1664 }
1665
1666 async fn handle_code_map(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1667 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1668 let code_map: CodeMap = Self::lenient_from_value(args).map_err(|e| {
1669 McpError::invalid_request(format!("Invalid CodeMap parameters: {e}"), None)
1670 })?;
1671
1672 let (slot, _session_guard) =
1674 self.session_for(&normalize_thread_id(&code_map.thread_id)).await;
1675 match crate::tools::code_map::handle_tool_call(&slot, code_map).await {
1676 Ok((text, structured)) => {
1677 let mut result = CallToolResult::success(vec![Content::text(text)]);
1678 result.structured_content = Some(structured);
1679 Ok(result)
1680 }
1681 Err(e) => Err(to_mcp_error("CodeMap", &e)),
1682 }
1683 }
1684}
1685
1686pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
1688 info!("Starting Winx MCP Server");
1689 let service = WinxService::new();
1690 let (stdin, stdout) = stdio();
1691 let transport = AsyncRwTransport::new_server(stdin, stdout);
1692 let server = service.serve(LegacyDiscoveryFallback::new(transport)).await?;
1693 server.waiting().await?;
1694 Ok(())
1695}
1696
1697#[cfg(test)]
1698mod session_registry_tests {
1699 use super::*;
1700
1701 #[tokio::test]
1702 async fn distinct_threads_get_distinct_sessions() {
1703 let svc = WinxService::new();
1704 let (a, _) = svc.session_for("thread_a").await;
1705 let (b, _) = svc.session_for("thread_b").await;
1706 assert!(!Arc::ptr_eq(&a, &b));
1709 let (a2, _) = svc.session_for("thread_a").await;
1711 assert!(Arc::ptr_eq(&a, &a2));
1712 }
1713
1714 #[tokio::test]
1715 async fn empty_thread_id_falls_back_to_last_active() {
1716 let svc = WinxService::new();
1717 let (_a, _) = svc.session_for("thread_a").await;
1718 let (b, _) = svc.session_for("thread_b").await; let (fallback, _) = svc.session_for("").await;
1721 assert!(Arc::ptr_eq(&b, &fallback));
1722 assert!(svc.active_slot().await.is_some());
1723 }
1724
1725 #[tokio::test]
1726 async fn strict_isolation_empty_thread_id_does_not_steal_active_session() {
1727 let svc = WinxService::with_isolation(SessionIsolation::Strict);
1728 let (a, _) = svc.session_for("thread_a").await; let (anon, _) = svc.session_for("").await;
1730 assert!(!Arc::ptr_eq(&a, &anon));
1732 let (anon2, _) = svc.session_for("").await;
1734 assert!(Arc::ptr_eq(&anon, &anon2));
1735 let (b, _) = svc.session_for("thread_b").await;
1737 assert!(!Arc::ptr_eq(&a, &b));
1738 }
1739
1740 #[tokio::test]
1741 async fn lru_eviction_caps_live_sessions() {
1742 let svc = WinxService::new();
1743 for i in 0..(MAX_SESSIONS + 5) {
1744 let (_, _) = svc.session_for(&format!("t{i}")).await;
1745 }
1746 let reg = svc.sessions.lock().await;
1747 assert!(reg.slots.len() <= MAX_SESSIONS, "session count {} over cap", reg.slots.len());
1748 }
1749
1750 #[tokio::test]
1751 async fn in_flight_session_is_not_evicted() {
1752 let svc = WinxService::new();
1753 let (_keep_slot, _keep_guard) = svc.session_for("keep").await;
1755 for i in 0..(MAX_SESSIONS + 10) {
1758 let (_, _) = svc.session_for(&format!("filler{i}")).await;
1759 }
1760 let reg = svc.sessions.lock().await;
1761 assert!(
1762 reg.slots.contains_key("keep"),
1763 "an in-flight session must survive LRU eviction churn"
1764 );
1765 }
1766}
1767
1768#[cfg(test)]
1769mod task_lifecycle_tests {
1770 use super::*;
1771
1772 #[tokio::test]
1773 async fn interrupt_waits_until_main_shell_is_reusable() {
1774 let service = WinxService::new();
1775 let initialized = service
1776 .handle_initialize(Some(serde_json::json!({
1777 "type": "first_call",
1778 "any_workspace_path": std::env::temp_dir(),
1779 "mode_name": "wcgw",
1780 "thread_id": "task_cancel_regression"
1781 })))
1782 .await;
1783 assert!(initialized.is_ok(), "test session failed to initialize: {initialized:?}");
1784
1785 let worker = {
1786 let service = service.clone();
1787 tokio::spawn(async move {
1788 service
1789 .handle_bash_command(Some(serde_json::json!({
1790 "action_json": {
1791 "type": "command",
1792 "command": "sleep 30",
1793 "is_background": false
1794 },
1795 "thread_id": "task_cancel_regression"
1796 })))
1797 .await
1798 })
1799 };
1800 tokio::time::sleep(Duration::from_millis(300)).await;
1801 worker.abort();
1802 service.interrupt_task_thread("task_cancel_regression").await;
1803 let interrupted = tokio::time::timeout(Duration::from_secs(5), worker).await;
1804 assert!(
1805 interrupted.is_ok_and(|joined| joined.is_err_and(|error| error.is_cancelled())),
1806 "aborted task worker did not settle"
1807 );
1808
1809 let recovery = service
1810 .handle_bash_command(Some(serde_json::json!({
1811 "action_json": {
1812 "type": "command",
1813 "command": "printf task-shell-recovered",
1814 "is_background": false
1815 },
1816 "thread_id": "task_cancel_regression"
1817 })))
1818 .await;
1819 let rendered = format!("{recovery:?}");
1820 assert!(
1821 recovery.is_ok() && rendered.contains("task-shell-recovered"),
1822 "shell was not reusable after cancellation: {rendered}"
1823 );
1824 }
1825}
1826
1827#[cfg(test)]
1828mod schema_tests {
1829 #![allow(clippy::expect_used)]
1830
1831 use super::{root_uri_to_path, schema_to_input_schema, strip_schema_titles, winx_tools};
1832 use rmcp::model::{ProtocolVersion, TaskSupport};
1833 use rmcp::ServerHandler;
1834 use serde_json::json;
1835 use std::path::PathBuf;
1836
1837 #[test]
1838 fn strips_titles_from_schema_nodes_only() {
1839 let mut v = json!({
1840 "type": "object",
1841 "title": "ShouldGo",
1842 "properties": {
1843 "title": { "type": "string", "title": "InnerGoes" }
1846 }
1847 });
1848 strip_schema_titles(&mut v);
1849 assert!(v.get("title").is_none(), "schema-node title not stripped");
1850 let props = v.get("properties").and_then(serde_json::Value::as_object);
1851 assert!(
1852 props.is_some_and(|p| p.contains_key("title")),
1853 "property key named 'title' must be preserved"
1854 );
1855 assert!(
1856 props.and_then(|p| p.get("title")).and_then(|t| t.get("title")).is_none(),
1857 "inner schema title not stripped"
1858 );
1859 }
1860
1861 #[test]
1862 fn real_tool_schema_carries_no_titles() {
1863 let schema = schema_to_input_schema::<crate::types::Initialize>();
1864 let blob = serde_json::to_string(&*schema).unwrap_or_default();
1865 assert!(!blob.contains("\"title\""), "tool schema still contains titles: {blob}");
1866 }
1867
1868 #[test]
1869 fn initialize_schema_has_no_dangling_refs() {
1870 let schema = schema_to_input_schema::<crate::types::Initialize>();
1876 let blob = serde_json::to_string(&*schema).unwrap_or_default();
1877 assert!(
1878 !blob.contains("#/definitions/"),
1879 "schema has a draft-07 #/definitions ref (dangling for 2020-12 clients): {blob}"
1880 );
1881 assert!(
1882 !blob.contains("definitions/ModeName"),
1883 "ModeName is referenced but never defined: {blob}"
1884 );
1885 assert!(
1887 blob.contains("wcgw") && blob.contains("architect") && blob.contains("code_writer"),
1888 "mode_name enum not inlined: {blob}"
1889 );
1890 }
1891
1892 #[test]
1893 fn advertises_stable_tasks_protocol_and_capability() {
1894 let info = ServerHandler::get_info(&super::WinxService::new());
1895 assert_eq!(info.protocol_version, ProtocolVersion::V_2025_11_25);
1896 let tasks = info.capabilities.tasks.expect("tasks capability");
1897 assert!(tasks.supports_tools_call());
1898 assert!(tasks.supports_list());
1899 assert!(tasks.supports_cancel());
1900 }
1901
1902 #[test]
1903 fn bash_task_support_and_code_map_output_schema_are_advertised() {
1904 let tools = winx_tools();
1905 let bash = tools.iter().find(|tool| tool.name == "BashCommand").expect("BashCommand");
1906 assert_eq!(bash.task_support(), TaskSupport::Optional);
1907
1908 let code_map = tools.iter().find(|tool| tool.name == "CodeMap").expect("CodeMap");
1909 let output = code_map.output_schema.as_ref().expect("CodeMap outputSchema");
1910 assert!(output.get("properties").is_some_and(|properties| {
1911 properties.as_object().is_some_and(|properties| properties.contains_key("truncated"))
1912 }));
1913 let blob = serde_json::to_string(output).unwrap_or_default();
1914 assert!(!blob.contains("\"format\":\"uint\""), "unsupported uint format: {blob}");
1915 }
1916
1917 #[test]
1918 fn local_root_uri_decoding_rejects_remote_authorities() {
1919 assert_eq!(
1920 root_uri_to_path("file:///tmp/a%20project"),
1921 Some(PathBuf::from("/tmp/a project"))
1922 );
1923 assert_eq!(
1924 root_uri_to_path("file://localhost/tmp/project"),
1925 Some(PathBuf::from("/tmp/project"))
1926 );
1927 assert_eq!(root_uri_to_path("file://remote.example/tmp/project"), None);
1928 assert_eq!(root_uri_to_path("https://example.com/project"), None);
1929 }
1930}
1931
1932#[cfg(test)]
1933mod discovery_compat_tests {
1934 #![allow(clippy::expect_used)]
1935
1936 use super::{LegacyDiscoveryFallback, WinxService};
1937 use rmcp::{transport::async_rw::AsyncRwTransport, ServiceExt};
1938 use serde_json::{json, Value};
1939 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1940
1941 #[tokio::test]
1942 async fn discover_probe_falls_back_without_closing_legacy_handshake() {
1943 let (client, server) = tokio::io::duplex(32 * 1024);
1944 let (server_read, server_write) = tokio::io::split(server);
1945 let transport = AsyncRwTransport::new_server(server_read, server_write);
1946 let task = tokio::spawn(async move {
1947 let running = WinxService::new()
1948 .serve(LegacyDiscoveryFallback::new(transport))
1949 .await
1950 .expect("server should accept initialize after discovery fallback");
1951 running.waiting().await.expect("server should shut down cleanly");
1952 });
1953
1954 let (client_read, mut client_write) = tokio::io::split(client);
1955 let mut client_read = BufReader::new(client_read);
1956
1957 client_write
1958 .write_all(
1959 b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{}}\n",
1960 )
1961 .await
1962 .expect("write discovery probe");
1963 let mut line = String::new();
1964 client_read.read_line(&mut line).await.expect("read discovery response");
1965 let response: Value = serde_json::from_str(&line).expect("valid discovery response");
1966 assert_eq!(response["id"], 1);
1967 assert_eq!(response["error"]["code"], -32601);
1968
1969 line.clear();
1970 let initialize = json!({
1971 "jsonrpc": "2.0",
1972 "id": 2,
1973 "method": "initialize",
1974 "params": {
1975 "protocolVersion": "2025-11-25",
1976 "capabilities": {},
1977 "clientInfo": { "name": "discovery-fallback-test", "version": "1" }
1978 }
1979 });
1980 client_write
1981 .write_all(format!("{initialize}\n").as_bytes())
1982 .await
1983 .expect("write initialize request");
1984 client_read.read_line(&mut line).await.expect("read initialize response");
1985 let response: Value = serde_json::from_str(&line).expect("valid initialize response");
1986 assert_eq!(response["id"], 2);
1987 assert_eq!(response["result"]["protocolVersion"], "2025-11-25");
1988
1989 drop(client_read);
1990 drop(client_write);
1991 task.await.expect("server task");
1992 }
1993}
1994
1995#[cfg(test)]
1996mod error_mapping_tests {
1997 use super::*;
1998 use rmcp::model::ErrorCode;
1999 use std::path::PathBuf;
2000
2001 fn code_of(err: &WinxError) -> ErrorCode {
2002 to_mcp_error("Tool", err).code
2003 }
2004
2005 #[test]
2006 fn client_caused_errors_map_to_invalid_request() {
2007 assert_eq!(
2009 code_of(&WinxError::RecoverableSuggestionError {
2010 message: "bad arg".into(),
2011 suggestion: "try x".into(),
2012 }),
2013 ErrorCode::INVALID_REQUEST,
2014 );
2015 assert_eq!(
2017 code_of(&WinxError::ParseError("unexpected token".into())),
2018 ErrorCode::INVALID_REQUEST,
2019 );
2020 assert_eq!(
2022 code_of(&WinxError::FileAccessError {
2023 path: PathBuf::from("/nope"),
2024 message: "no such file".into(),
2025 }),
2026 ErrorCode::INVALID_REQUEST,
2027 );
2028 }
2029
2030 #[test]
2031 fn server_caused_errors_stay_internal_error() {
2032 assert_eq!(
2033 code_of(&WinxError::IoError(std::io::Error::other("disk gone"))),
2034 ErrorCode::INTERNAL_ERROR,
2035 );
2036 assert_eq!(
2037 code_of(&WinxError::BashStateLockError("poisoned".into())),
2038 ErrorCode::INTERNAL_ERROR,
2039 );
2040 }
2041}
2042
2043#[cfg(all(test, feature = "loom"))]
2058mod loom_tests {
2059 use loom::sync::atomic::{AtomicUsize, Ordering};
2060 use loom::sync::Arc;
2061
2062 #[derive(Clone)]
2063 struct PinModel {
2064 count: Arc<AtomicUsize>,
2065 }
2066
2067 struct GuardModel {
2068 count: Arc<AtomicUsize>,
2069 }
2070
2071 impl PinModel {
2072 fn new() -> Self {
2073 Self { count: Arc::new(AtomicUsize::new(0)) }
2074 }
2075 fn acquire(&self) -> GuardModel {
2076 self.count.fetch_add(1, Ordering::SeqCst);
2077 GuardModel { count: self.count.clone() }
2078 }
2079 fn is_pinned(&self) -> bool {
2080 self.count.load(Ordering::SeqCst) > 0
2081 }
2082 }
2083
2084 impl Drop for GuardModel {
2085 fn drop(&mut self) {
2086 self.count.fetch_sub(1, Ordering::SeqCst);
2087 }
2088 }
2089
2090 #[test]
2096 fn loom_concurrent_guards_balance_to_zero() {
2097 loom::model(|| {
2098 let pin = PinModel::new();
2099 let p1 = pin.clone();
2100 let p2 = pin.clone();
2101 let h1 = loom::thread::spawn(move || drop(p1.acquire()));
2102 let h2 = loom::thread::spawn(move || drop(p2.acquire()));
2103 assert!(h1.join().is_ok());
2104 assert!(h2.join().is_ok());
2105 assert_eq!(pin.count.load(Ordering::SeqCst), 0, "pin must settle back to 0");
2106 });
2107 }
2108
2109 #[test]
2113 fn loom_live_guard_always_reads_pinned() {
2114 loom::model(|| {
2115 let pin = PinModel::new();
2116 let observer = pin.clone();
2117 let held = pin.acquire();
2118 let worker = pin.clone();
2121 let h = loom::thread::spawn(move || drop(worker.acquire()));
2122 assert!(observer.is_pinned(), "session with a live guard must read pinned");
2123 assert!(h.join().is_ok());
2124 assert!(observer.is_pinned(), "still pinned while the first guard lives");
2125 drop(held);
2126 assert!(!observer.is_pinned(), "all guards gone -> unpinned");
2127 });
2128 }
2129}