Skip to main content

winx_code_agent/
server.rs

1//! Winx MCP Server implementation using rmcp 1.8
2//! Core MCP tools only - High performance shell and file management
3
4use 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
43/// Map a domain [`WinxError`] to the right JSON-RPC error kind.
44///
45/// Client-caused failures (bad params, security violations, mode restrictions,
46/// state-not-initialized, ambiguous/not-found edits) become `invalid_request`
47/// so the model knows the fix is on its side; genuine server-side failures
48/// (PTY spawn, IO, lock poisoning, persistence) stay `internal_error`.
49fn to_mcp_error(tool: &str, err: &WinxError) -> McpError {
50    let msg = format!("{tool} failed: {err}");
51    // Exhaustive on purpose — NO wildcard arm. A new `WinxError` variant must be
52    // classified by hand (client vs server) or the build breaks. This is the
53    // guard that keeps the JSON-RPC error codes honest over time.
54    match err {
55        // Client-caused: the model can fix its own input or usage. -> invalid_request
56        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        // Server-caused: genuine internal faults the model cannot fix. -> internal_error
79        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
97/// Type alias for the shared bash state - uses `tokio::sync::Mutex` for async safety
98pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
99
100/// Preserve the legacy 2025-11-25 handshake when a newer client probes with
101/// `server/discover` first. The draft stateless protocol requires legacy
102/// servers to answer `Method not found`, after which the client falls back to
103/// `initialize`; rmcp 1.8 otherwise treats the probe as a fatal init error.
104struct 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
161/// Helper function to create JSON schema from schemars Schema
162fn 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    // schemars stamps a redundant `title` (usually just the type/field name) on
166    // every schema node; the LLM pays tokens for it on every tool call for zero
167    // signal. Strip it — context-aware, so a user data field literally named
168    // "title" is never touched.
169    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
177/// Remove integer formats that are valid JSON Schema annotations but rejected
178/// by some MCP clients (notably Claude Code's schema compiler).
179fn 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
198/// Recursively remove `title` keys from JSON-Schema nodes only.
199///
200/// A dict is treated as a schema node when it carries a schema-shaped key
201/// (`type`/`$ref`/`properties`/`items`/`enum`/`const`/`anyOf`/`allOf`/`oneOf`/
202/// `additionalProperties`). This mirrors wcgw's `recursive_purge_dict_key` so a
203/// property whose *name* is "title" keeps its value.
204fn 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
478/// Upper bound on concurrently-live sessions. Each session owns a PTY (a real
479/// bash process), so we evict the least-recently-used one past this to avoid
480/// leaking shells across many short-lived `thread_id`s.
481const MAX_SESSIONS: usize = 32;
482
483/// Per-`thread_id` shell sessions. Each `thread_id` gets its own
484/// `BashState`/PTY, so concurrent threads (or HTTP clients sharing the service)
485/// never execute in each other's shell. Tools that don't carry a `thread_id`
486/// (legacy clients) fall back to the most recently active session.
487#[derive(Default)]
488struct SessionRegistry {
489    slots: HashMap<String, SharedBashState>,
490    /// Last-use timestamps for LRU eviction.
491    last_used: HashMap<String, Instant>,
492    /// In-flight operation count per session. A session whose count is > 0 (it
493    /// has a live [`SessionGuard`]) is never chosen as an LRU eviction victim, so
494    /// a long-running call can't have its shell pulled out from under it.
495    in_flight: HashMap<String, SessionPin>,
496    /// Most recently addressed session, used as the fallback for tool calls
497    /// that omit a `thread_id`.
498    last_active: Option<String>,
499}
500
501/// Lock-free pin counter for one session. A live [`SessionGuard`] keeps the
502/// count `> 0`, which marks the session as in-flight so LRU eviction skips it.
503/// Clones share the same counter — the registry hands a clone to each
504/// concurrent call. The acquire/release discipline (the guard's `Drop` runs
505/// *outside* the registry lock) is loom model-checked by `loom_tests`, which
506/// mirrors this exact logic; keep the two in lockstep.
507#[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    /// Mark a new operation in flight, returning the RAII guard that releases it.
520    fn acquire(&self) -> SessionGuard {
521        self.count.fetch_add(1, Ordering::SeqCst);
522        SessionGuard { count: self.count.clone() }
523    }
524
525    /// Whether any operation is currently in flight on this session.
526    fn is_pinned(&self) -> bool {
527        self.count.load(Ordering::SeqCst) > 0
528    }
529}
530
531/// RAII marker that a session has an operation in flight. Bumps the session's
532/// in-flight counter on creation and drops it on `Drop` — a plain synchronous
533/// `Drop`, so it works even though the registry behind it lives in an async
534/// `tokio::Mutex`. While any guard is alive the session is pinned against LRU
535/// eviction.
536struct 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/// How an empty `thread_id` is resolved by the session registry.
547#[derive(Clone, Copy, PartialEq, Eq, Debug)]
548pub enum SessionIsolation {
549    /// Local stdio transport — a single client. An empty `thread_id` falls back
550    /// to the last-active session (legacy single-client convenience).
551    Lenient,
552    /// Remote HTTP transport — multiple clients behind one shared bearer token.
553    /// The last-active fallback is disabled, so an empty `thread_id` from one
554    /// client can never resolve to another client's shell; such calls get a
555    /// dedicated anonymous slot instead. Clients must use the `thread_id` that
556    /// `Initialize` hands back.
557    ///
558    /// Residual: two clients that deliberately send the *same* explicit
559    /// `thread_id` still share a shell. Closing that needs per-client tokens,
560    /// which the single shared-token model intentionally doesn't provide.
561    Strict,
562}
563
564/// Winx service. Holds the per-thread session registry.
565#[derive(Clone)]
566pub struct WinxService {
567    sessions: Arc<Mutex<SessionRegistry>>,
568    tasks: Arc<Mutex<TaskRegistry>>,
569    root_bootstrap: Arc<Mutex<()>>,
570    /// Version information for the service
571    pub version: String,
572    /// How empty `thread_id`s are resolved (see [`SessionIsolation`]).
573    isolation: SessionIsolation,
574}
575
576impl Default for WinxService {
577    fn default() -> Self {
578        Self::new()
579    }
580}
581
582impl WinxService {
583    /// Create a new `WinxService` for the local stdio transport (lenient,
584    /// single-client session isolation).
585    pub fn new() -> Self {
586        Self::with_isolation(SessionIsolation::Lenient)
587    }
588
589    /// Create a `WinxService` with an explicit session-isolation policy. The
590    /// HTTP transport uses [`SessionIsolation::Strict`].
591    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    /// Resolve the session slot for a `thread_id`, creating it if absent.
603    ///
604    /// An empty `thread_id` resolves, under [`SessionIsolation::Lenient`], to the
605    /// most recently active session (the compatibility path for tools — and older
606    /// clients — that don't send one); under [`SessionIsolation::Strict`] it gets
607    /// a dedicated anonymous slot so remote clients can't land in each other's shell.
608    /// Marks the slot as most-recently-used and evicts the LRU session when over
609    /// [`MAX_SESSIONS`].
610    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                // Single local client: reuse the most recently active shell.
615                SessionIsolation::Lenient => {
616                    reg.last_active.clone().unwrap_or_else(|| "default".to_string())
617                }
618                // Many remote clients sharing one token: never resolve to
619                // someone else's active shell. Empty thread_id gets its own slot.
620                SessionIsolation::Strict => "anonymous".to_string(),
621            }
622        } else {
623            thread_id.to_string()
624        };
625
626        // Evict the LRU session if adding a brand-new key would exceed the cap —
627        // but never evict a session with an operation in flight (that would pull
628        // the shell out from under a concurrent long-running call). If every other
629        // session is busy we briefly exceed the cap rather than corrupt one.
630        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    /// The most recently active session slot, without creating one. Used by
664    /// session-agnostic surfaces (e.g. the handoff prompt).
665    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    /// Run one task-augmented foreground shell command through to completion.
671    /// Normal `BashCommand` calls intentionally yield after a short wait; an MCP
672    /// Task keeps polling that same PTY and exposes one final `CallToolResult`.
673    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        // Ctrl-C is asynchronous. Drain until the shell's prompt marker arrives
765        // before tasks/cancel returns, otherwise the very next tool call sees
766        // the stale `command_running=true` flag and reports a false conflict.
767        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    /// Bootstrap a local single-client session from the client's first MCP Root.
803    /// Roots are part of stable 2025-11-25, although rmcp 1.8 marks the API as
804    /// deprecated in anticipation of the later SEP-2577 draft.
805    #[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        // Refuse non-local file authorities (file://host/path).
898        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
905/// `ServerHandler` implementation
906impl 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        // Tasks were added in the stable 2025-11-25 revision. rmcp negotiates
921        // with older clients during initialize, so raising the server ceiling
922        // preserves compatibility with clients on earlier revisions.
923        .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        // A changed Roots list is only used as a retry if Winx is still
935        // uninitialized. Never switch an active shell's workspace implicitly.
936        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                // Cancellation is terminal. A worker racing with tasks/cancel
1032                // must never overwrite Cancelled with Completed.
1033                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        // This prompt embeds git status/diff and file context, so scrub it too —
1189        // it returns a GetPromptResult and bypasses the call_tool choke point.
1190        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        // Audit trail: one structured line per tool call, including the outcome
1232        // and wall-clock. Successes were previously silent — only errors logged —
1233        // which made debugging remote (ChatGPT) sessions guesswork.
1234        // Redact the audit line too: it embeds tool arguments (a BashCommand's
1235        // command line, a file path) that can carry secrets into the logs.
1236        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        // Scrub credentials (provider keys, JWTs, PEM blocks, user:pass URLs)
1254        // from everything leaving the server — shell output, file contents,
1255        // search results — before it reaches the model. `WINX_NO_REDACT` opts out.
1256        let result = match result {
1257            Ok(mut call) => {
1258                redact_result(&mut call);
1259                Ok(call)
1260            }
1261            Err(mut error) => {
1262                // The error message is returned to the client/model verbatim and
1263                // often echoes a path, env value, or command output that can carry
1264                // a secret — scrub it the same as success content.
1265                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            // error.message was already redacted above.
1274            Err(error) => warn!(tool = %tool, ms, "tool call error — {summary}: {}", error.message),
1275        }
1276        result
1277    }
1278}
1279
1280/// Build a short, non-sensitive audit summary of a tool call's arguments.
1281fn 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
1318/// Scrub credential patterns from a tool result's text blocks and structured
1319/// content, in place. Central choke point so every tool is covered (see
1320/// [`crate::utils::redact`]).
1321fn 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        // Sections the ContextSave `description` should contain, tailored to the
1432        // mode: architect produces a plan (no edits), the others produce a status
1433        // + pending-issues handoff (wcgw parity: WCGW_KT vs ARCHITECT_KT).
1434        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    /// Deserialize `args` into `T`, retrying once after JSON-decoding any string
1468    /// field that is itself an encoded object/array. LLMs sometimes send a nested
1469    /// param (e.g. `code_writer_config`) as a JSON string instead of an object;
1470    /// wcgw applies the same leniency in its tool dispatch.
1471    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        // Resolve the session key. A first_call may omit thread_id; generate one
1507        // here and write it back so the handler and the registry agree on the id.
1508        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            // Initialize historically returns a generated thread_id, but local
1539            // clients that rely on MCP Roots may not know it. Resolve an omitted
1540            // id to the session selected by `session_for` before the lower-level
1541            // BashCommand validation runs.
1542            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                // Return a real image content block (not base64 as text) so the
1658                // model can actually see the image. rmcp's `Content::image`
1659                // takes (data, mime_type).
1660                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        // Read-only: no persist_state (nothing in the session changes).
1673        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
1686/// Create and start the Winx MCP server
1687pub 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        // Two thread_ids must own two separate slots — the whole point of the
1707        // registry: thread B never executes in thread A's shell.
1708        assert!(!Arc::ptr_eq(&a, &b));
1709        // Same id round-trips to the same slot.
1710        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; // now last-active
1719                                                        // A tool call without a thread_id reuses the most recently active slot.
1720        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; // would be "last-active" under Lenient
1729        let (anon, _) = svc.session_for("").await;
1730        // Strict mode: an empty thread_id must NOT resolve to another client's shell.
1731        assert!(!Arc::ptr_eq(&a, &anon));
1732        // Anonymous calls share one dedicated slot rather than hijacking named ones.
1733        let (anon2, _) = svc.session_for("").await;
1734        assert!(Arc::ptr_eq(&anon, &anon2));
1735        // Explicit thread_ids stay isolated as always.
1736        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        // "keep" is created first (so it's the LRU candidate) and held busy.
1754        let (_keep_slot, _keep_guard) = svc.session_for("keep").await;
1755        // Saturate the cap and then churn well past it; eviction must skip the
1756        // in-flight "keep" and evict idle fillers instead.
1757        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                // a user field literally named "title" must survive as a key,
1844                // and its inner schema's own title must be stripped.
1845                "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        // The Initialize input embeds ModeName. A manual JsonSchema impl used to
1871        // emit a `$ref` to `#/definitions/ModeName` that was never populated —
1872        // strict MCP clients (Cline, and likely ChatGPT) reject the tool call
1873        // with "Reference not found: #/definitions/ModeName". Guard against any
1874        // dangling/definitions-style ref reappearing.
1875        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        // mode_name must be a concrete, self-contained string enum.
1886        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        // A validation suggestion is the model's to fix, not a server fault.
2008        assert_eq!(
2009            code_of(&WinxError::RecoverableSuggestionError {
2010                message: "bad arg".into(),
2011                suggestion: "try x".into(),
2012            }),
2013            ErrorCode::INVALID_REQUEST,
2014        );
2015        // Parse failures come from the model's input.
2016        assert_eq!(
2017            code_of(&WinxError::ParseError("unexpected token".into())),
2018            ErrorCode::INVALID_REQUEST,
2019        );
2020        // A bad path the model handed us is something it can correct.
2021        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/// Loom model-check of the [`SessionPin`] counting discipline — the one piece
2044/// of session state touched off the registry lock (a guard's `Drop` decrements
2045/// while a concurrent eviction may be reading it via `is_pinned`). Loom can't
2046/// model the surrounding `tokio::Mutex`, so we isolate the lock-free counter and
2047/// exhaustively check it under every interleaving.
2048///
2049/// `SessionPin` itself stays on std atomics: instrumenting it would force every
2050/// `WinxService` test (which constructs pins outside `loom::model`) to panic
2051/// under `--all-features`. Instead `PinModel` below mirrors `SessionPin`'s exact
2052/// logic — `acquire` = `fetch_add(SeqCst)`, guard `Drop` = `fetch_sub(SeqCst)`,
2053/// `is_pinned` = `load(SeqCst) > 0`. Keep the two in lockstep.
2054///
2055/// Built only under the `loom` feature. Run with:
2056///   cargo test --features loom --lib loom_
2057#[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    /// Two concurrent in-flight ops on the same session: under *every* thread
2091    /// interleaving each `acquire` is balanced by its guard's `Drop`, so the pin
2092    /// returns to exactly 0 and never underflows. An underflow would wrap the
2093    /// counter to a huge value, making a finished session read as permanently
2094    /// in-flight and leak past LRU eviction forever.
2095    #[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    /// One op stays in flight while another runs concurrently: the observer must
2110    /// never see the live session as unpinned (which the eviction filter would
2111    /// read as "safe to evict", pulling the shell out from under the running op).
2112    #[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            // A second op starts and finishes concurrently; throughout, the
2119            // first guard keeps the session pinned for the observer.
2120            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}