Skip to main content

winx_code_agent/
server.rs

1//! Winx MCP Server implementation using rmcp 1.7
2//! Core MCP tools only - High performance shell and file management
3
4use rmcp::{
5    model::{
6        Annotated, CallToolRequestParams, CallToolResult, Content, GetPromptRequestParams,
7        GetPromptResult, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
8        PaginatedRequestParams, Prompt, PromptMessage, PromptMessageRole, ProtocolVersion,
9        RawResource, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
10        ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
11    },
12    service::{RequestContext, RoleServer},
13    transport::stdio,
14    ErrorData as McpError, ServerHandler, ServiceExt,
15};
16use schemars::schema_for;
17use serde_json::Value;
18use std::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, CodeMap, ContextSave, FileWriteOrEdit, Initialize,
33    MultiFileEdit, ReadFiles, ReadImage, UndoEdit,
34};
35
36/// Map a domain [`WinxError`] to the right JSON-RPC error kind.
37///
38/// Client-caused failures (bad params, security violations, mode restrictions,
39/// state-not-initialized, ambiguous/not-found edits) become `invalid_request`
40/// so the model knows the fix is on its side; genuine server-side failures
41/// (PTY spawn, IO, lock poisoning, persistence) stay `internal_error`.
42fn to_mcp_error(tool: &str, err: &WinxError) -> McpError {
43    let msg = format!("{tool} failed: {err}");
44    // Exhaustive on purpose — NO wildcard arm. A new `WinxError` variant must be
45    // classified by hand (client vs server) or the build breaks. This is the
46    // guard that keeps the JSON-RPC error codes honest over time.
47    match err {
48        // Client-caused: the model can fix its own input or usage. -> invalid_request
49        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        // Server-caused: genuine internal faults the model cannot fix. -> internal_error
72        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
90/// Type alias for the shared bash state - uses `tokio::sync::Mutex` for async safety
91pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
92
93/// Helper function to create JSON schema from schemars Schema
94fn 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    // schemars stamps a redundant `title` (usually just the type/field name) on
98    // every schema node; the LLM pays tokens for it on every tool call for zero
99    // signal. Strip it — context-aware, so a user data field literally named
100    // "title" is never touched.
101    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
108/// Recursively remove `title` keys from JSON-Schema nodes only.
109///
110/// A dict is treated as a schema node when it carries a schema-shaped key
111/// (`type`/`$ref`/`properties`/`items`/`enum`/`const`/`anyOf`/`allOf`/`oneOf`/
112/// `additionalProperties`). This mirrors wcgw's `recursive_purge_dict_key` so a
113/// property whose *name* is "title" keeps its value.
114fn 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 MULTI_FILE_EDIT_DESCRIPTION: &str =
265    "- 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). \
266     - 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. \
267     - 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. \
268     - Provide 2+ files; for a single file use FileWriteOrEdit. Do not list the same file twice. \
269     - If a write fails mid-batch (rare: disk/permissions), it stops and reports which files were already written; those are not rolled back.";
270
271const UNDO_EDIT_DESCRIPTION: &str =
272    "- Reverts a file to the content it had before the last FileWriteOrEdit/MultiFileEdit you made to it THIS session. \
273     - Use this to back out a wrong edit instead of re-typing the old content. \
274     - 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). \
275     - 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. \
276     - Provide file_path (absolute, ~ allowed).";
277
278const CONTEXT_SAVE_DESCRIPTION: &str =
279    "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
280     - Provide random 3 word unqiue id or whatever user provided. \
281     - Leave project path as empty string if no project path";
282
283const CODE_MAP_DESCRIPTION: &str =
284    "- Navigate code structure via tree-sitter - the semantic layer plain grep/rg can't give you. Pick an `operation`: \
285     - 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. \
286     - 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`. \
287     - Scope either operation with `path` (file or directory; empty = whole workspace); cap with `max_results`. gitignore-aware, workspace-confined, works in every mode. \
288     - 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`. \
289     - For plain-text/regex search or file discovery, use rg / grep / fd / find via BashCommand.";
290
291static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
292static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
293
294fn winx_tools() -> Vec<Tool> {
295    WINX_TOOLS.get_or_init(build_winx_tools).clone()
296}
297
298fn build_winx_tools() -> Vec<Tool> {
299    vec![
300        mcp_tool::<Initialize>(
301            "Initialize",
302            INITIALIZE_DESCRIPTION,
303            ToolAnnotations::new().read_only(true).open_world(false),
304        ),
305        mcp_tool::<BashCommand>(
306            "BashCommand",
307            BASH_COMMAND_DESCRIPTION,
308            ToolAnnotations::new().destructive(true).open_world(true),
309        ),
310        mcp_tool::<ReadFiles>(
311            "ReadFiles",
312            READ_FILES_DESCRIPTION,
313            ToolAnnotations::new().read_only(true).open_world(false),
314        ),
315        mcp_tool::<FileWriteOrEdit>(
316            "FileWriteOrEdit",
317            FILE_WRITE_OR_EDIT_DESCRIPTION,
318            ToolAnnotations::new().destructive(true).open_world(false),
319        ),
320        mcp_tool::<MultiFileEdit>(
321            "MultiFileEdit",
322            MULTI_FILE_EDIT_DESCRIPTION,
323            ToolAnnotations::new().destructive(true).open_world(false),
324        ),
325        mcp_tool::<UndoEdit>(
326            "UndoEdit",
327            UNDO_EDIT_DESCRIPTION,
328            ToolAnnotations::new().destructive(true).open_world(false),
329        ),
330        mcp_tool::<ContextSave>(
331            "ContextSave",
332            CONTEXT_SAVE_DESCRIPTION,
333            ToolAnnotations::new().destructive(false).open_world(false),
334        ),
335        mcp_tool::<ReadImage>(
336            "ReadImage",
337            "Read an image from the shell.",
338            ToolAnnotations::new().read_only(true).open_world(false),
339        ),
340        mcp_tool::<CodeMap>(
341            "CodeMap",
342            CODE_MAP_DESCRIPTION,
343            ToolAnnotations::new().read_only(true).open_world(false),
344        ),
345    ]
346}
347
348fn winx_prompts() -> Vec<Prompt> {
349    WINX_PROMPTS
350        .get_or_init(|| {
351            vec![Prompt::new(
352                "KnowledgeTransfer",
353                Some("Summarize current Winx state, workspace context, and handoff notes."),
354                None,
355            )]
356        })
357        .clone()
358}
359
360fn append_command_section<const N: usize>(
361    output: &mut String,
362    title: &str,
363    cwd: &Path,
364    args: [&str; N],
365) {
366    let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
367        return;
368    };
369    if !command_output.status.success() {
370        return;
371    }
372
373    let content = String::from_utf8_lossy(&command_output.stdout);
374    if content.trim().is_empty() {
375        return;
376    }
377
378    let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
379}
380
381/// Upper bound on concurrently-live sessions. Each session owns a PTY (a real
382/// bash process), so we evict the least-recently-used one past this to avoid
383/// leaking shells across many short-lived `thread_id`s.
384const MAX_SESSIONS: usize = 32;
385
386/// Per-`thread_id` shell sessions. Each `thread_id` gets its own
387/// `BashState`/PTY, so concurrent threads (or HTTP clients sharing the service)
388/// never execute in each other's shell. Tools that don't carry a `thread_id`
389/// (legacy clients) fall back to the most recently active session.
390#[derive(Default)]
391struct SessionRegistry {
392    slots: HashMap<String, SharedBashState>,
393    /// Last-use timestamps for LRU eviction.
394    last_used: HashMap<String, Instant>,
395    /// In-flight operation count per session. A session whose count is > 0 (it
396    /// has a live [`SessionGuard`]) is never chosen as an LRU eviction victim, so
397    /// a long-running call can't have its shell pulled out from under it.
398    in_flight: HashMap<String, SessionPin>,
399    /// Most recently addressed session, used as the fallback for tool calls
400    /// that omit a `thread_id`.
401    last_active: Option<String>,
402}
403
404/// Lock-free pin counter for one session. A live [`SessionGuard`] keeps the
405/// count `> 0`, which marks the session as in-flight so LRU eviction skips it.
406/// Clones share the same counter — the registry hands a clone to each
407/// concurrent call. The acquire/release discipline (the guard's `Drop` runs
408/// *outside* the registry lock) is loom model-checked by `loom_tests`, which
409/// mirrors this exact logic; keep the two in lockstep.
410#[derive(Clone)]
411struct SessionPin {
412    count: Arc<AtomicUsize>,
413}
414
415impl Default for SessionPin {
416    fn default() -> Self {
417        Self { count: Arc::new(AtomicUsize::new(0)) }
418    }
419}
420
421impl SessionPin {
422    /// Mark a new operation in flight, returning the RAII guard that releases it.
423    fn acquire(&self) -> SessionGuard {
424        self.count.fetch_add(1, Ordering::SeqCst);
425        SessionGuard { count: self.count.clone() }
426    }
427
428    /// Whether any operation is currently in flight on this session.
429    fn is_pinned(&self) -> bool {
430        self.count.load(Ordering::SeqCst) > 0
431    }
432}
433
434/// RAII marker that a session has an operation in flight. Bumps the session's
435/// in-flight counter on creation and drops it on `Drop` — a plain synchronous
436/// `Drop`, so it works even though the registry behind it lives in an async
437/// `tokio::Mutex`. While any guard is alive the session is pinned against LRU
438/// eviction.
439struct SessionGuard {
440    count: Arc<AtomicUsize>,
441}
442
443impl Drop for SessionGuard {
444    fn drop(&mut self) {
445        self.count.fetch_sub(1, Ordering::SeqCst);
446    }
447}
448
449/// How an empty `thread_id` is resolved by the session registry.
450#[derive(Clone, Copy, PartialEq, Eq, Debug)]
451pub enum SessionIsolation {
452    /// Local stdio transport — a single client. An empty `thread_id` falls back
453    /// to the last-active session (legacy single-client convenience).
454    Lenient,
455    /// Remote HTTP transport — multiple clients behind one shared bearer token.
456    /// The last-active fallback is disabled, so an empty `thread_id` from one
457    /// client can never resolve to another client's shell; such calls get a
458    /// dedicated anonymous slot instead. Clients must use the `thread_id` that
459    /// `Initialize` hands back.
460    ///
461    /// Residual: two clients that deliberately send the *same* explicit
462    /// `thread_id` still share a shell. Closing that needs per-client tokens,
463    /// which the single shared-token model intentionally doesn't provide.
464    Strict,
465}
466
467/// Winx service. Holds the per-thread session registry.
468#[derive(Clone)]
469pub struct WinxService {
470    sessions: Arc<Mutex<SessionRegistry>>,
471    /// Version information for the service
472    pub version: String,
473    /// How empty `thread_id`s are resolved (see [`SessionIsolation`]).
474    isolation: SessionIsolation,
475}
476
477impl Default for WinxService {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483impl WinxService {
484    /// Create a new `WinxService` for the local stdio transport (lenient,
485    /// single-client session isolation).
486    pub fn new() -> Self {
487        Self::with_isolation(SessionIsolation::Lenient)
488    }
489
490    /// Create a `WinxService` with an explicit session-isolation policy. The
491    /// HTTP transport uses [`SessionIsolation::Strict`].
492    pub fn with_isolation(isolation: SessionIsolation) -> Self {
493        info!(?isolation, "Creating new WinxService instance");
494        Self {
495            sessions: Arc::new(Mutex::new(SessionRegistry::default())),
496            version: env!("CARGO_PKG_VERSION").to_string(),
497            isolation,
498        }
499    }
500
501    /// Resolve the session slot for a `thread_id`, creating it if absent.
502    ///
503    /// An empty `thread_id` resolves, under [`SessionIsolation::Lenient`], to the
504    /// most recently active session (the compatibility path for tools — and older
505    /// clients — that don't send one); under [`SessionIsolation::Strict`] it gets
506    /// a dedicated anonymous slot so remote clients can't land in each other's shell.
507    /// Marks the slot as most-recently-used and evicts the LRU session when over
508    /// [`MAX_SESSIONS`].
509    async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
510        let mut reg = self.sessions.lock().await;
511        let key = if thread_id.is_empty() {
512            match self.isolation {
513                // Single local client: reuse the most recently active shell.
514                SessionIsolation::Lenient => {
515                    reg.last_active.clone().unwrap_or_else(|| "default".to_string())
516                }
517                // Many remote clients sharing one token: never resolve to
518                // someone else's active shell. Empty thread_id gets its own slot.
519                SessionIsolation::Strict => "anonymous".to_string(),
520            }
521        } else {
522            thread_id.to_string()
523        };
524
525        // Evict the LRU session if adding a brand-new key would exceed the cap —
526        // but never evict a session with an operation in flight (that would pull
527        // the shell out from under a concurrent long-running call). If every other
528        // session is busy we briefly exceed the cap rather than corrupt one.
529        if !reg.slots.contains_key(&key) && reg.slots.len() >= MAX_SESSIONS {
530            let victim = reg
531                .last_used
532                .iter()
533                .filter(|(k, _)| **k != key)
534                .filter(|(k, _)| reg.in_flight.get(k.as_str()).map_or(true, |p| !p.is_pinned()))
535                .min_by_key(|(_, t)| **t)
536                .map(|(k, _)| k.clone());
537            if let Some(victim) = victim {
538                reg.slots.remove(&victim);
539                reg.last_used.remove(&victim);
540                reg.in_flight.remove(&victim);
541                if reg.last_active.as_deref() == Some(victim.as_str()) {
542                    reg.last_active = None;
543                }
544                warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
545            } else {
546                warn!(
547                    "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
548                );
549            }
550        }
551
552        let slot =
553            reg.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
554        let pin = reg.in_flight.entry(key.clone()).or_default().clone();
555        reg.last_used.insert(key.clone(), Instant::now());
556        if !thread_id.is_empty() {
557            reg.last_active = Some(key);
558        }
559        (slot, pin.acquire())
560    }
561
562    /// The most recently active session slot, without creating one. Used by
563    /// session-agnostic surfaces (e.g. the handoff prompt).
564    async fn active_slot(&self) -> Option<SharedBashState> {
565        let reg = self.sessions.lock().await;
566        reg.last_active.as_ref().and_then(|key| reg.slots.get(key).cloned())
567    }
568}
569
570/// `ServerHandler` implementation
571impl ServerHandler for WinxService {
572    fn get_info(&self) -> ServerInfo {
573        ServerInfo::new(
574            ServerCapabilities::builder()
575                .enable_tools()
576                .enable_resources()
577                .enable_prompts()
578                .build(),
579        )
580        .with_server_info(
581            Implementation::new("winx-mcp-server", self.version.clone())
582                .with_title("Winx High-Performance MCP"),
583        )
584        // Advertise 2025-06-18 (structured tool output + output schemas). rmcp
585        // negotiates `min(peer, server)`, so older clients still get 2024-11-05
586        // automatically — bumping the ceiling never breaks them.
587        .with_protocol_version(ProtocolVersion::V_2025_06_18)
588        .with_instructions(
589                "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
590        )
591    }
592
593    async fn list_tools(
594        &self,
595        _request: Option<PaginatedRequestParams>,
596        _context: RequestContext<RoleServer>,
597    ) -> Result<ListToolsResult, McpError> {
598        Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
599    }
600
601    async fn list_resources(
602        &self,
603        _param: Option<PaginatedRequestParams>,
604        _context: RequestContext<RoleServer>,
605    ) -> Result<ListResourcesResult, McpError> {
606        Ok(ListResourcesResult {
607            resources: vec![Annotated {
608                raw: RawResource {
609                    uri: "file://readme".into(),
610                    name: "README".into(),
611                    description: Some("Project README documentation".into()),
612                    mime_type: Some("text/markdown".into()),
613                    size: None,
614                    title: None,
615                    icons: None,
616                    meta: None,
617                },
618                annotations: None,
619            }],
620            next_cursor: None,
621            meta: None,
622        })
623    }
624
625    async fn list_prompts(
626        &self,
627        _request: Option<PaginatedRequestParams>,
628        _context: RequestContext<RoleServer>,
629    ) -> Result<ListPromptsResult, McpError> {
630        Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
631    }
632
633    async fn get_prompt(
634        &self,
635        request: GetPromptRequestParams,
636        _context: RequestContext<RoleServer>,
637    ) -> Result<GetPromptResult, McpError> {
638        if request.name != "KnowledgeTransfer" {
639            return Err(McpError::invalid_request(
640                format!("Unknown prompt: {}", request.name),
641                None,
642            ));
643        }
644
645        // This prompt embeds git status/diff and file context, so scrub it too —
646        // it returns a GetPromptResult and bypasses the call_tool choke point.
647        let text =
648            crate::utils::redact::redact(&self.knowledge_transfer_prompt_text().await).into_owned();
649
650        Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
651            .with_description("Knowledge transfer handoff prompt"))
652    }
653
654    async fn read_resource(
655        &self,
656        param: ReadResourceRequestParams,
657        _context: RequestContext<RoleServer>,
658    ) -> Result<ReadResourceResult, McpError> {
659        let content = match param.uri.as_ref() {
660            "file://readme" => match tokio::fs::read_to_string("README.md").await {
661                Ok(content) => vec![ResourceContents::text(
662                    crate::utils::redact::redact(&content).into_owned(),
663                    param.uri.clone(),
664                )],
665                Err(_) => vec![ResourceContents::text(
666                    "README.md not found".to_string(),
667                    param.uri.clone(),
668                )],
669            },
670            _ => {
671                return Err(McpError::invalid_request(
672                    format!("Unknown resource URI: {}", param.uri),
673                    None,
674                ));
675            }
676        };
677
678        Ok(ReadResourceResult::new(content))
679    }
680
681    async fn call_tool(
682        &self,
683        param: CallToolRequestParams,
684        _context: RequestContext<RoleServer>,
685    ) -> Result<CallToolResult, McpError> {
686        let tool = param.name.to_string();
687        let args_value = param.arguments.map(Value::Object);
688        // Audit trail: one structured line per tool call, including the outcome
689        // and wall-clock. Successes were previously silent — only errors logged —
690        // which made debugging remote (ChatGPT) sessions guesswork.
691        let summary = audit_summary(&tool, args_value.as_ref());
692        let started = std::time::Instant::now();
693
694        let result = match tool.as_str() {
695            "Initialize" => self.handle_initialize(args_value).await,
696            "BashCommand" => self.handle_bash_command(args_value).await,
697            "ReadFiles" => self.handle_read_files(args_value).await,
698            "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
699            "MultiFileEdit" => self.handle_multi_file_edit(args_value).await,
700            "UndoEdit" => self.handle_undo_edit(args_value).await,
701            "ContextSave" => self.handle_context_save(args_value).await,
702            "ReadImage" => self.handle_read_image(args_value).await,
703            "CodeMap" => self.handle_code_map(args_value).await,
704            _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
705        };
706
707        // Scrub credentials (provider keys, JWTs, PEM blocks, user:pass URLs)
708        // from everything leaving the server — shell output, file contents,
709        // search results — before it reaches the model. `WINX_NO_REDACT` opts out.
710        let result = match result {
711            Ok(mut call) => {
712                redact_result(&mut call);
713                Ok(call)
714            }
715            err => err,
716        };
717
718        let ms = started.elapsed().as_millis();
719        match &result {
720            Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
721            Err(error) => warn!(
722                tool = %tool, ms,
723                "tool call error — {summary}: {}",
724                crate::utils::redact::redact(&error.message)
725            ),
726        }
727        result
728    }
729}
730
731/// Build a short, non-sensitive audit summary of a tool call's arguments.
732fn audit_summary(tool: &str, args: Option<&Value>) -> String {
733    let Some(args) = args else {
734        return "(no args)".to_string();
735    };
736    let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
737    let clip = |text: String| text.chars().take(100).collect::<String>();
738    match tool {
739        "BashCommand" => {
740            let action = args.get("action_json");
741            let cmd = action
742                .and_then(|a| a.get("command"))
743                .and_then(Value::as_str)
744                .or_else(|| args.get("command").and_then(Value::as_str));
745            if let Some(cmd) = cmd {
746                format!("cmd={:?}", clip(cmd.to_string()))
747            } else {
748                let kind =
749                    action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
750                format!("action={kind}")
751            }
752        }
753        "FileWriteOrEdit" | "ReadImage" | "UndoEdit" => format!("path={}", s("file_path")),
754        "MultiFileEdit" => {
755            format!("files={}", args.get("files").and_then(Value::as_array).map_or(0, Vec::len))
756        }
757        "ReadFiles" => {
758            format!(
759                "files={}",
760                args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
761            )
762        }
763        "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
764        "ContextSave" => format!("id={}", s("id")),
765        "CodeMap" => format!("op={} path={} name={}", s("operation"), s("path"), s("name")),
766        _ => String::new(),
767    }
768}
769
770/// Scrub credential patterns from a tool result's text blocks and structured
771/// content, in place. Central choke point so every tool is covered (see
772/// [`crate::utils::redact`]).
773fn redact_result(result: &mut CallToolResult) {
774    use rmcp::model::RawContent;
775    for content in &mut result.content {
776        if let RawContent::Text(text) = &mut content.raw {
777            if let std::borrow::Cow::Owned(scrubbed) = crate::utils::redact::redact(&text.text) {
778                text.text = scrubbed;
779            }
780        }
781    }
782    if let Some(structured) = result.structured_content.as_mut() {
783        crate::utils::redact::redact_json(structured);
784    }
785}
786
787impl WinxService {
788    async fn knowledge_transfer_prompt_text(&self) -> String {
789        let mut text = String::from(
790            "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",
791        );
792
793        let state_snapshot = if let Some(slot) = self.active_slot().await {
794            let guard = slot.lock().await;
795            guard.as_ref().map(|state| {
796                let whitelist = state
797                    .whitelist_for_overwrite
798                    .iter()
799                    .take(12)
800                    .map(|(path, data)| {
801                        format!(
802                            "- {} ({:.1}% read, {} lines)",
803                            path,
804                            data.get_percentage_read(),
805                            data.total_lines
806                        )
807                    })
808                    .collect::<Vec<_>>();
809                (
810                    state.current_thread_id.clone(),
811                    state.workspace_root.clone(),
812                    state.cwd.clone(),
813                    state.mode.to_string(),
814                    whitelist,
815                    state.whitelist_for_overwrite.len(),
816                )
817            })
818        } else {
819            None
820        };
821
822        let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
823            state_snapshot
824        else {
825            text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
826            return text;
827        };
828
829        let _ = writeln!(
830            text,
831            "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
832            workspace_root.display(),
833            cwd.display()
834        );
835
836        if !whitelist.is_empty() {
837            text.push_str("\n# Recently readable files\n");
838            text.push_str(&whitelist.join("\n"));
839            text.push('\n');
840        }
841
842        let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
843        if !active_files.is_empty() {
844            text.push_str("\n# Active files by Winx usage\n");
845            for file in active_files.iter().take(12) {
846                let _ = writeln!(text, "- {file}");
847            }
848        }
849
850        if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
851            let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
852            let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
853        }
854
855        append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
856        append_command_section(
857            &mut text,
858            "Git diff stat",
859            &workspace_root,
860            ["diff", "--stat", "HEAD"],
861        );
862
863        // Sections the ContextSave `description` should contain, tailored to the
864        // mode: architect produces a plan (no edits), the others produce a status
865        // + pending-issues handoff (wcgw parity: WCGW_KT vs ARCHITECT_KT).
866        let sections = if mode == "architect" {
867            "\n# Sections for the ContextSave description (architect mode)\n\
868             - `# Objective` — project and task objective.\n\
869             - `# All user instructions` — everything the user asked, verbatim.\n\
870             - `# Designed plan` — the plan you designed, in detail.\n\
871             - Provide all relevant file paths so the next agent can resume; err toward more.\n"
872        } else {
873            "\n# Sections for the ContextSave description\n\
874             - `# Objective` — project and task objective.\n\
875             - `# All user instructions` — everything the user asked, verbatim.\n\
876             - `# Current status` — what's already done (not what's left).\n\
877             - `# Pending issues with snippets` — verbatim errors/tracebacks/commands; be verbose.\n\
878             - `# Build and development instructions` — how to build/run/test; leave empty if unknown.\n\
879             - Provide all relevant file paths so the next agent can resume; err toward more.\n"
880        };
881        text.push_str(sections);
882
883        text.push_str(
884            "\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",
885        );
886
887        text
888    }
889
890    async fn persist_state(&self, slot: &SharedBashState) {
891        let guard = slot.lock().await;
892        if let Some(state) = guard.as_ref() {
893            if let Err(error) = state.save_state_to_disk() {
894                warn!("Failed to persist bash state: {}", error);
895            }
896        }
897    }
898
899    /// Deserialize `args` into `T`, retrying once after JSON-decoding any string
900    /// field that is itself an encoded object/array. LLMs sometimes send a nested
901    /// param (e.g. `code_writer_config`) as a JSON string instead of an object;
902    /// wcgw applies the same leniency in its tool dispatch.
903    fn lenient_from_value<T: serde::de::DeserializeOwned>(
904        args: Value,
905    ) -> Result<T, serde_json::Error> {
906        match serde_json::from_value::<T>(args.clone()) {
907            Ok(value) => Ok(value),
908            Err(first_err) => {
909                let Value::Object(mut map) = args else {
910                    return Err(first_err);
911                };
912                let mut changed = false;
913                for value in map.values_mut() {
914                    if let Value::String(text) = value {
915                        if let Ok(parsed) = serde_json::from_str::<Value>(text) {
916                            if parsed.is_object() || parsed.is_array() {
917                                *value = parsed;
918                                changed = true;
919                            }
920                        }
921                    }
922                }
923                if changed {
924                    serde_json::from_value::<T>(Value::Object(map))
925                } else {
926                    Err(first_err)
927                }
928            }
929        }
930    }
931
932    async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
933        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
934        let mut initialize: Initialize = Self::lenient_from_value(args).map_err(|e| {
935            McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
936        })?;
937
938        // Resolve the session key. A first_call may omit thread_id; generate one
939        // here and write it back so the handler and the registry agree on the id.
940        let mut thread_id = normalize_thread_id(&initialize.thread_id);
941        if thread_id.is_empty() {
942            thread_id = generate_thread_id();
943            initialize.thread_id.clone_from(&thread_id);
944        }
945        let (slot, _session_guard) = self.session_for(&thread_id).await;
946
947        match crate::tools::initialize::handle_tool_call(&slot, initialize).await {
948            Ok(result) => {
949                self.persist_state(&slot).await;
950                Ok(CallToolResult::success(vec![Content::text(result)]))
951            }
952            Err(e) => Err(to_mcp_error("Initialize", &e)),
953        }
954    }
955
956    async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
957        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
958        let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
959            McpError::invalid_request(
960                format!(
961                    "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
962                ),
963                None,
964            )
965        })?;
966
967        let (slot, _session_guard) =
968            self.session_for(&normalize_thread_id(&bash_command.thread_id)).await;
969        match crate::tools::bash_command::handle_tool_call(&slot, bash_command).await {
970            Ok(output) => {
971                self.persist_state(&slot).await;
972                Ok(CallToolResult::success(vec![Content::text(output)]))
973            }
974            Err(e) => Err(to_mcp_error("BashCommand", &e)),
975        }
976    }
977
978    async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
979        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
980        let read_files: ReadFiles = Self::lenient_from_value(args).map_err(|e| {
981            McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
982        })?;
983
984        let (slot, _session_guard) =
985            self.session_for(&normalize_thread_id(&read_files.thread_id)).await;
986        match crate::tools::read_files::handle_tool_call(&slot, read_files).await {
987            Ok(result) => {
988                self.persist_state(&slot).await;
989                Ok(CallToolResult::success(vec![Content::text(result)]))
990            }
991            Err(e) => Err(to_mcp_error("ReadFiles", &e)),
992        }
993    }
994
995    async fn handle_file_write_or_edit(
996        &self,
997        args: Option<Value>,
998    ) -> Result<CallToolResult, McpError> {
999        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1000        let file_write_or_edit: FileWriteOrEdit = Self::lenient_from_value(args).map_err(|e| {
1001            McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
1002        })?;
1003
1004        let (slot, _session_guard) =
1005            self.session_for(&normalize_thread_id(&file_write_or_edit.thread_id)).await;
1006        match crate::tools::file_write_or_edit::handle_tool_call(&slot, file_write_or_edit).await {
1007            Ok(result) => {
1008                self.persist_state(&slot).await;
1009                Ok(CallToolResult::success(vec![Content::text(result)]))
1010            }
1011            Err(e) => Err(to_mcp_error("FileWriteOrEdit", &e)),
1012        }
1013    }
1014
1015    async fn handle_multi_file_edit(
1016        &self,
1017        args: Option<Value>,
1018    ) -> Result<CallToolResult, McpError> {
1019        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1020        let multi: MultiFileEdit = Self::lenient_from_value(args).map_err(|e| {
1021            McpError::invalid_request(format!("Invalid MultiFileEdit parameters: {e}"), None)
1022        })?;
1023
1024        let (slot, _session_guard) = self.session_for(&normalize_thread_id(&multi.thread_id)).await;
1025        match crate::tools::multi_file_edit::handle_tool_call(&slot, multi).await {
1026            Ok(result) => {
1027                self.persist_state(&slot).await;
1028                Ok(CallToolResult::success(vec![Content::text(result)]))
1029            }
1030            Err(e) => Err(to_mcp_error("MultiFileEdit", &e)),
1031        }
1032    }
1033
1034    async fn handle_undo_edit(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1035        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1036        let undo: UndoEdit = Self::lenient_from_value(args).map_err(|e| {
1037            McpError::invalid_request(format!("Invalid UndoEdit parameters: {e}"), None)
1038        })?;
1039
1040        let (slot, _session_guard) = self.session_for(&normalize_thread_id(&undo.thread_id)).await;
1041        match crate::tools::undo_edit::handle_tool_call(&slot, undo).await {
1042            Ok(result) => {
1043                self.persist_state(&slot).await;
1044                Ok(CallToolResult::success(vec![Content::text(result)]))
1045            }
1046            Err(e) => Err(to_mcp_error("UndoEdit", &e)),
1047        }
1048    }
1049
1050    async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1051        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1052        let context_save: ContextSave = Self::lenient_from_value(args).map_err(|e| {
1053            McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
1054        })?;
1055
1056        let (slot, _session_guard) =
1057            self.session_for(&normalize_thread_id(&context_save.thread_id)).await;
1058        match crate::tools::context_save::handle_tool_call(&slot, context_save).await {
1059            Ok(result) => {
1060                self.persist_state(&slot).await;
1061                Ok(CallToolResult::success(vec![Content::text(result)]))
1062            }
1063            Err(e) => Err(to_mcp_error("ContextSave", &e)),
1064        }
1065    }
1066
1067    async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1068        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1069        let read_image: ReadImage = Self::lenient_from_value(args).map_err(|e| {
1070            McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
1071        })?;
1072
1073        let (slot, _session_guard) =
1074            self.session_for(&normalize_thread_id(&read_image.thread_id)).await;
1075        match crate::tools::read_image::handle_tool_call(&slot, read_image).await {
1076            Ok((mime_type, base64_data)) => {
1077                self.persist_state(&slot).await;
1078                // Return a real image content block (not base64 as text) so the
1079                // model can actually see the image. rmcp's `Content::image`
1080                // takes (data, mime_type).
1081                Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
1082            }
1083            Err(e) => Err(to_mcp_error("ReadImage", &e)),
1084        }
1085    }
1086
1087    async fn handle_code_map(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1088        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1089        let code_map: CodeMap = Self::lenient_from_value(args).map_err(|e| {
1090            McpError::invalid_request(format!("Invalid CodeMap parameters: {e}"), None)
1091        })?;
1092
1093        // Read-only: no persist_state (nothing in the session changes).
1094        let (slot, _session_guard) =
1095            self.session_for(&normalize_thread_id(&code_map.thread_id)).await;
1096        match crate::tools::code_map::handle_tool_call(&slot, code_map).await {
1097            Ok((text, structured)) => {
1098                let mut result = CallToolResult::success(vec![Content::text(text)]);
1099                result.structured_content = Some(structured);
1100                Ok(result)
1101            }
1102            Err(e) => Err(to_mcp_error("CodeMap", &e)),
1103        }
1104    }
1105}
1106
1107/// Create and start the Winx MCP server
1108pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
1109    info!("Starting Winx MCP Server");
1110    let service = WinxService::new();
1111    let server = service.serve(stdio()).await?;
1112    server.waiting().await?;
1113    Ok(())
1114}
1115
1116#[cfg(test)]
1117mod session_registry_tests {
1118    use super::*;
1119
1120    #[tokio::test]
1121    async fn distinct_threads_get_distinct_sessions() {
1122        let svc = WinxService::new();
1123        let (a, _) = svc.session_for("thread_a").await;
1124        let (b, _) = svc.session_for("thread_b").await;
1125        // Two thread_ids must own two separate slots — the whole point of the
1126        // registry: thread B never executes in thread A's shell.
1127        assert!(!Arc::ptr_eq(&a, &b));
1128        // Same id round-trips to the same slot.
1129        let (a2, _) = svc.session_for("thread_a").await;
1130        assert!(Arc::ptr_eq(&a, &a2));
1131    }
1132
1133    #[tokio::test]
1134    async fn empty_thread_id_falls_back_to_last_active() {
1135        let svc = WinxService::new();
1136        let (_a, _) = svc.session_for("thread_a").await;
1137        let (b, _) = svc.session_for("thread_b").await; // now last-active
1138                                                        // A tool call without a thread_id reuses the most recently active slot.
1139        let (fallback, _) = svc.session_for("").await;
1140        assert!(Arc::ptr_eq(&b, &fallback));
1141        assert!(svc.active_slot().await.is_some());
1142    }
1143
1144    #[tokio::test]
1145    async fn strict_isolation_empty_thread_id_does_not_steal_active_session() {
1146        let svc = WinxService::with_isolation(SessionIsolation::Strict);
1147        let (a, _) = svc.session_for("thread_a").await; // would be "last-active" under Lenient
1148        let (anon, _) = svc.session_for("").await;
1149        // Strict mode: an empty thread_id must NOT resolve to another client's shell.
1150        assert!(!Arc::ptr_eq(&a, &anon));
1151        // Anonymous calls share one dedicated slot rather than hijacking named ones.
1152        let (anon2, _) = svc.session_for("").await;
1153        assert!(Arc::ptr_eq(&anon, &anon2));
1154        // Explicit thread_ids stay isolated as always.
1155        let (b, _) = svc.session_for("thread_b").await;
1156        assert!(!Arc::ptr_eq(&a, &b));
1157    }
1158
1159    #[tokio::test]
1160    async fn lru_eviction_caps_live_sessions() {
1161        let svc = WinxService::new();
1162        for i in 0..(MAX_SESSIONS + 5) {
1163            let (_, _) = svc.session_for(&format!("t{i}")).await;
1164        }
1165        let reg = svc.sessions.lock().await;
1166        assert!(reg.slots.len() <= MAX_SESSIONS, "session count {} over cap", reg.slots.len());
1167    }
1168
1169    #[tokio::test]
1170    async fn in_flight_session_is_not_evicted() {
1171        let svc = WinxService::new();
1172        // "keep" is created first (so it's the LRU candidate) and held busy.
1173        let (_keep_slot, _keep_guard) = svc.session_for("keep").await;
1174        // Saturate the cap and then churn well past it; eviction must skip the
1175        // in-flight "keep" and evict idle fillers instead.
1176        for i in 0..(MAX_SESSIONS + 10) {
1177            let (_, _) = svc.session_for(&format!("filler{i}")).await;
1178        }
1179        let reg = svc.sessions.lock().await;
1180        assert!(
1181            reg.slots.contains_key("keep"),
1182            "an in-flight session must survive LRU eviction churn"
1183        );
1184    }
1185}
1186
1187#[cfg(test)]
1188mod schema_tests {
1189    use super::{schema_to_input_schema, strip_schema_titles};
1190    use serde_json::json;
1191
1192    #[test]
1193    fn strips_titles_from_schema_nodes_only() {
1194        let mut v = json!({
1195            "type": "object",
1196            "title": "ShouldGo",
1197            "properties": {
1198                // a user field literally named "title" must survive as a key,
1199                // and its inner schema's own title must be stripped.
1200                "title": { "type": "string", "title": "InnerGoes" }
1201            }
1202        });
1203        strip_schema_titles(&mut v);
1204        assert!(v.get("title").is_none(), "schema-node title not stripped");
1205        let props = v.get("properties").and_then(serde_json::Value::as_object);
1206        assert!(
1207            props.is_some_and(|p| p.contains_key("title")),
1208            "property key named 'title' must be preserved"
1209        );
1210        assert!(
1211            props.and_then(|p| p.get("title")).and_then(|t| t.get("title")).is_none(),
1212            "inner schema title not stripped"
1213        );
1214    }
1215
1216    #[test]
1217    fn real_tool_schema_carries_no_titles() {
1218        let schema = schema_to_input_schema::<crate::types::Initialize>();
1219        let blob = serde_json::to_string(&*schema).unwrap_or_default();
1220        assert!(!blob.contains("\"title\""), "tool schema still contains titles: {blob}");
1221    }
1222}
1223
1224#[cfg(test)]
1225mod error_mapping_tests {
1226    use super::*;
1227    use rmcp::model::ErrorCode;
1228    use std::path::PathBuf;
1229
1230    fn code_of(err: &WinxError) -> ErrorCode {
1231        to_mcp_error("Tool", err).code
1232    }
1233
1234    #[test]
1235    fn client_caused_errors_map_to_invalid_request() {
1236        // A validation suggestion is the model's to fix, not a server fault.
1237        assert_eq!(
1238            code_of(&WinxError::RecoverableSuggestionError {
1239                message: "bad arg".into(),
1240                suggestion: "try x".into(),
1241            }),
1242            ErrorCode::INVALID_REQUEST,
1243        );
1244        // Parse failures come from the model's input.
1245        assert_eq!(
1246            code_of(&WinxError::ParseError("unexpected token".into())),
1247            ErrorCode::INVALID_REQUEST,
1248        );
1249        // A bad path the model handed us is something it can correct.
1250        assert_eq!(
1251            code_of(&WinxError::FileAccessError {
1252                path: PathBuf::from("/nope"),
1253                message: "no such file".into(),
1254            }),
1255            ErrorCode::INVALID_REQUEST,
1256        );
1257    }
1258
1259    #[test]
1260    fn server_caused_errors_stay_internal_error() {
1261        assert_eq!(
1262            code_of(&WinxError::IoError(std::io::Error::other("disk gone"))),
1263            ErrorCode::INTERNAL_ERROR,
1264        );
1265        assert_eq!(
1266            code_of(&WinxError::BashStateLockError("poisoned".into())),
1267            ErrorCode::INTERNAL_ERROR,
1268        );
1269    }
1270}
1271
1272/// Loom model-check of the [`SessionPin`] counting discipline — the one piece
1273/// of session state touched off the registry lock (a guard's `Drop` decrements
1274/// while a concurrent eviction may be reading it via `is_pinned`). Loom can't
1275/// model the surrounding `tokio::Mutex`, so we isolate the lock-free counter and
1276/// exhaustively check it under every interleaving.
1277///
1278/// `SessionPin` itself stays on std atomics: instrumenting it would force every
1279/// `WinxService` test (which constructs pins outside `loom::model`) to panic
1280/// under `--all-features`. Instead `PinModel` below mirrors `SessionPin`'s exact
1281/// logic — `acquire` = `fetch_add(SeqCst)`, guard `Drop` = `fetch_sub(SeqCst)`,
1282/// `is_pinned` = `load(SeqCst) > 0`. Keep the two in lockstep.
1283///
1284/// Built only under the `loom` feature. Run with:
1285///   cargo test --features loom --lib loom_
1286#[cfg(all(test, feature = "loom"))]
1287mod loom_tests {
1288    use loom::sync::atomic::{AtomicUsize, Ordering};
1289    use loom::sync::Arc;
1290
1291    #[derive(Clone)]
1292    struct PinModel {
1293        count: Arc<AtomicUsize>,
1294    }
1295
1296    struct GuardModel {
1297        count: Arc<AtomicUsize>,
1298    }
1299
1300    impl PinModel {
1301        fn new() -> Self {
1302            Self { count: Arc::new(AtomicUsize::new(0)) }
1303        }
1304        fn acquire(&self) -> GuardModel {
1305            self.count.fetch_add(1, Ordering::SeqCst);
1306            GuardModel { count: self.count.clone() }
1307        }
1308        fn is_pinned(&self) -> bool {
1309            self.count.load(Ordering::SeqCst) > 0
1310        }
1311    }
1312
1313    impl Drop for GuardModel {
1314        fn drop(&mut self) {
1315            self.count.fetch_sub(1, Ordering::SeqCst);
1316        }
1317    }
1318
1319    /// Two concurrent in-flight ops on the same session: under *every* thread
1320    /// interleaving each `acquire` is balanced by its guard's `Drop`, so the pin
1321    /// returns to exactly 0 and never underflows. An underflow would wrap the
1322    /// counter to a huge value, making a finished session read as permanently
1323    /// in-flight and leak past LRU eviction forever.
1324    #[test]
1325    fn loom_concurrent_guards_balance_to_zero() {
1326        loom::model(|| {
1327            let pin = PinModel::new();
1328            let p1 = pin.clone();
1329            let p2 = pin.clone();
1330            let h1 = loom::thread::spawn(move || drop(p1.acquire()));
1331            let h2 = loom::thread::spawn(move || drop(p2.acquire()));
1332            assert!(h1.join().is_ok());
1333            assert!(h2.join().is_ok());
1334            assert_eq!(pin.count.load(Ordering::SeqCst), 0, "pin must settle back to 0");
1335        });
1336    }
1337
1338    /// One op stays in flight while another runs concurrently: the observer must
1339    /// never see the live session as unpinned (which the eviction filter would
1340    /// read as "safe to evict", pulling the shell out from under the running op).
1341    #[test]
1342    fn loom_live_guard_always_reads_pinned() {
1343        loom::model(|| {
1344            let pin = PinModel::new();
1345            let observer = pin.clone();
1346            let held = pin.acquire();
1347            // A second op starts and finishes concurrently; throughout, the
1348            // first guard keeps the session pinned for the observer.
1349            let worker = pin.clone();
1350            let h = loom::thread::spawn(move || drop(worker.acquire()));
1351            assert!(observer.is_pinned(), "session with a live guard must read pinned");
1352            assert!(h.join().is_ok());
1353            assert!(observer.is_pinned(), "still pinned while the first guard lives");
1354            drop(held);
1355            assert!(!observer.is_pinned(), "all guards gone -> unpinned");
1356        });
1357    }
1358}