Skip to main content

winx_code_agent/
server.rs

1//! Winx MCP Server implementation using rmcp 0.12.0
2//! Core MCP tools only - High performance shell and file management
3
4use rmcp::{
5    model::{
6        Annotated, CallToolRequestParams, CallToolResult, Content, GetPromptRequestParams,
7        GetPromptResult, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
8        PaginatedRequestParams, Prompt, PromptMessage, PromptMessageRole, ProtocolVersion,
9        RawResource, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
10        ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
11    },
12    service::{RequestContext, RoleServer},
13    transport::stdio,
14    ErrorData as McpError, ServerHandler, ServiceExt,
15};
16use schemars::schema_for;
17use serde_json::Value;
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::Path;
21use std::process::Command;
22use std::sync::{Arc, OnceLock};
23use std::time::Instant;
24use tokio::sync::Mutex;
25use tracing::{info, warn};
26
27use crate::errors::WinxError;
28use crate::state::bash_state::generate_thread_id;
29use crate::state::BashState;
30use crate::types::{
31    normalize_thread_id, BashCommand, ContextSave, FileWriteOrEdit, Initialize, ReadFiles,
32    ReadImage,
33};
34
35/// Map a domain [`WinxError`] to the right JSON-RPC error kind.
36///
37/// Client-caused failures (bad params, security violations, mode restrictions,
38/// state-not-initialized, ambiguous/not-found edits) become `invalid_request`
39/// so the model knows the fix is on its side; genuine server-side failures
40/// (PTY spawn, IO, lock poisoning, persistence) stay `internal_error`.
41fn to_mcp_error(tool: &str, err: &WinxError) -> McpError {
42    let msg = format!("{tool} failed: {err}");
43    match err {
44        WinxError::BashStateNotInitialized
45        | WinxError::CommandNotAllowed(_)
46        | WinxError::PathSecurityError { .. }
47        | WinxError::ThreadIdMismatch(_)
48        | WinxError::ParameterValidationError { .. }
49        | WinxError::MissingParameterError { .. }
50        | WinxError::NullValueError { .. }
51        | WinxError::ArgumentParseError(_)
52        | WinxError::JsonParseError(_)
53        | WinxError::DeserializationError(_)
54        | WinxError::WorkspacePathError(_)
55        | WinxError::InvalidInput(_)
56        | WinxError::SearchReplaceSyntaxError(_)
57        | WinxError::SearchReplaceSyntaxErrorDetailed { .. }
58        | WinxError::SearchBlockNotFound(_)
59        | WinxError::SearchBlockAmbiguous { .. }
60        | WinxError::SearchBlockConflict { .. }
61        | WinxError::FileTooLarge { .. }
62        | WinxError::InteractiveCommandDetected { .. }
63        | WinxError::CommandAlreadyRunning { .. } => McpError::invalid_request(msg, None),
64        _ => McpError::internal_error(msg, None),
65    }
66}
67
68/// Type alias for the shared bash state - uses `tokio::sync::Mutex` for async safety
69pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
70
71/// Helper function to create JSON schema from schemars Schema
72fn schema_to_input_schema<T: schemars::JsonSchema>() -> Arc<serde_json::Map<String, Value>> {
73    let schema = schema_for!(T);
74    let value = serde_json::to_value(schema).unwrap_or(Value::Object(serde_json::Map::new()));
75    match value {
76        Value::Object(map) => Arc::new(map),
77        _ => Arc::new(serde_json::Map::new()),
78    }
79}
80
81fn mcp_tool<T: schemars::JsonSchema>(
82    name: &'static str,
83    description: &'static str,
84    annotations: ToolAnnotations,
85) -> Tool {
86    Tool::new(name, description, schema_to_input_schema::<T>()).with_annotations(annotations)
87}
88
89const INITIALIZE_DESCRIPTION: &str =
90    "- Always call this at the start of the conversation before using any of the shell tools from wcgw. \
91     - Use `any_workspace_path` to initialize the shell in the appropriate project directory. \
92     - If the user has mentioned a workspace or project root or any other file or folder use it to set `any_workspace_path`. \
93     - If user has mentioned any files use `initial_files_to_read` to read, use absolute paths only (~ allowed) \
94     - By default use mode \"wcgw\" \
95     - In \"code-writer\" mode, set the commands and globs which user asked to set, otherwise use 'all'. \
96     - Use type=\"first_call\" if it's the first call to this tool. \
97     - Use type=\"user_asked_mode_change\" if in a conversation user has asked to change mode. \
98     - Use type=\"reset_shell\" if in a conversation shell is not working after multiple tries. \
99     - Use type=\"user_asked_change_workspace\" if in a conversation user asked to change workspace";
100
101const BASH_COMMAND_DESCRIPTION: &str =
102    "- Execute a bash command. This is stateful (beware with subsequent calls). \
103     - Accepted payloads include action_json with an explicit type, action_json shorthand such as {\"command\":\"pwd\"}, or top-level shorthand such as {\"command\":\"pwd\"}. \
104     - Status of the command and the current working directory will always be returned at the end. \
105     - The first or the last line might be `(...truncated)` if the output is too long. \
106     - Always run `pwd` if you get any file or directory not found error to make sure you're not lost. \
107     - Do not run bg commands using \"&\", instead use this tool. \
108     - You must not use echo/cat to read/write files, use ReadFiles/FileWriteOrEdit \
109     - In order to check status of previous command, use `status_check` with empty command argument. \
110     - Only command is allowed to run at a time. You need to wait for any previous command to finish before running a new one. \
111     - 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. \
112     - Do not send Ctrl-c before checking for status till 10 minutes or whatever is appropriate for the program to finish. \
113     - Only run long running commands in background. Each background command is run in a new non-reusable shell. \
114     - On running a bg command you'll get a bg command id that you should use to get status or interact.";
115
116const READ_FILES_DESCRIPTION: &str =
117    "- Read full file content of one or more files. \
118     - 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. \
119     - Do NOT use this for binary files or images — use ReadImage for images. \
120     - Provide absolute paths only (~ allowed) \
121     - Only if the task requires line numbers understanding: \
122     - 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`";
123
124const FILE_WRITE_OR_EDIT_DESCRIPTION: &str =
125    "- Writes or edits a file based on the percentage of changes. \
126     - Prefer this over writing/editing files with BashCommand (echo/sed/redirects/heredocs). \
127     - For an edit, the file must have been read first with ReadFiles, otherwise the edit is rejected. \
128     - Use absolute path only (~ allowed). \
129     - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change \
130     - 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. \
131     - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks \
132     - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks. \
133     \
134     Instructions for editing files. \
135     # Example \
136     ## Input file \
137     ``` \
138     import numpy as np \
139     from impls import impl1, impl2 \
140     \
141     def hello(): \
142         \"print a greeting\" \
143         print(\"hello\") \
144     \
145     def call_hello(): \
146         \"call hello\" \
147         hello() \
148         print(\"Called\") \
149         impl1() \
150         hello() \
151         impl2() \
152     ``` \
153     ## Edit format on the input file \
154     ``` \
155     <<<<<<< SEARCH \
156     from impls import impl1, impl2 \
157     ======= \
158     from impls import impl1, impl2 \
159     from hello import hello as hello_renamed \
160     >>>>>>> REPLACE \
161     <<<<<<< SEARCH \
162     def hello(): \
163         \"print a greeting\" \
164         print(\"hello\") \
165     ======= \
166     >>>>>>> REPLACE \
167     <<<<<<< SEARCH \
168     def call_hello(): \
169         \"call hello\" \
170         hello() \
171     ======= \
172     def call_hello_renamed(): \
173         \"call hello renamed\" \
174         hello_renamed() \
175     >>>>>>> REPLACE \
176     <<<<<<< SEARCH \
177     impl1() \
178     hello() \
179     impl2() \
180     ======= \
181     impl1() \
182     hello_renamed() \
183     impl2() \
184     >>>>>>> REPLACE \
185     ``` \
186     # *SEARCH/REPLACE block* Rules: \
187     Every \"<<<<<<< SEARCH\" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc. \
188     Including multiple unique *SEARCH/REPLACE* blocks if needed. \
189     Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change. \
190     Keep *SEARCH/REPLACE* blocks concise. \
191     Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. \
192     Include just the changing lines, and a few surrounding lines (0-3 lines) if needed for uniqueness. \
193     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. \
194     Preserve leading spaces and indentations in both SEARCH and REPLACE blocks.";
195
196const CONTEXT_SAVE_DESCRIPTION: &str =
197    "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
198     - Provide random 3 word unqiue id or whatever user provided. \
199     - Leave project path as empty string if no project path";
200
201static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
202static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
203
204fn winx_tools() -> Vec<Tool> {
205    WINX_TOOLS.get_or_init(build_winx_tools).clone()
206}
207
208fn build_winx_tools() -> Vec<Tool> {
209    vec![
210        mcp_tool::<Initialize>(
211            "Initialize",
212            INITIALIZE_DESCRIPTION,
213            ToolAnnotations::new().read_only(true).open_world(false),
214        ),
215        mcp_tool::<BashCommand>(
216            "BashCommand",
217            BASH_COMMAND_DESCRIPTION,
218            ToolAnnotations::new().destructive(true).open_world(true),
219        ),
220        mcp_tool::<ReadFiles>(
221            "ReadFiles",
222            READ_FILES_DESCRIPTION,
223            ToolAnnotations::new().read_only(true).open_world(false),
224        ),
225        mcp_tool::<FileWriteOrEdit>(
226            "FileWriteOrEdit",
227            FILE_WRITE_OR_EDIT_DESCRIPTION,
228            ToolAnnotations::new().destructive(true).open_world(false),
229        ),
230        mcp_tool::<ContextSave>(
231            "ContextSave",
232            CONTEXT_SAVE_DESCRIPTION,
233            ToolAnnotations::new().destructive(false).open_world(false),
234        ),
235        mcp_tool::<ReadImage>(
236            "ReadImage",
237            "Read an image from the shell.",
238            ToolAnnotations::new().read_only(true).open_world(false),
239        ),
240    ]
241}
242
243fn winx_prompts() -> Vec<Prompt> {
244    WINX_PROMPTS
245        .get_or_init(|| {
246            vec![Prompt::new(
247                "KnowledgeTransfer",
248                Some("Summarize current Winx state, workspace context, and handoff notes."),
249                None,
250            )]
251        })
252        .clone()
253}
254
255fn append_command_section<const N: usize>(
256    output: &mut String,
257    title: &str,
258    cwd: &Path,
259    args: [&str; N],
260) {
261    let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
262        return;
263    };
264    if !command_output.status.success() {
265        return;
266    }
267
268    let content = String::from_utf8_lossy(&command_output.stdout);
269    if content.trim().is_empty() {
270        return;
271    }
272
273    let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
274}
275
276/// Upper bound on concurrently-live sessions. Each session owns a PTY (a real
277/// bash process), so we evict the least-recently-used one past this to avoid
278/// leaking shells across many short-lived `thread_id`s.
279const MAX_SESSIONS: usize = 32;
280
281/// Per-`thread_id` shell sessions. Each `thread_id` gets its own
282/// `BashState`/PTY, so concurrent threads (or HTTP clients sharing the service)
283/// never execute in each other's shell. Tools that don't carry a `thread_id`
284/// (legacy clients) fall back to the most recently active session.
285#[derive(Default)]
286struct SessionRegistry {
287    slots: HashMap<String, SharedBashState>,
288    /// Last-use timestamps for LRU eviction.
289    last_used: HashMap<String, Instant>,
290    /// Most recently addressed session, used as the fallback for tool calls
291    /// that omit a `thread_id`.
292    last_active: Option<String>,
293}
294
295/// Winx service. Holds the per-thread session registry.
296#[derive(Clone)]
297pub struct WinxService {
298    sessions: Arc<Mutex<SessionRegistry>>,
299    /// Version information for the service
300    pub version: String,
301}
302
303impl Default for WinxService {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309impl WinxService {
310    /// Create a new `WinxService` instance
311    pub fn new() -> Self {
312        info!("Creating new WinxService instance");
313        Self {
314            sessions: Arc::new(Mutex::new(SessionRegistry::default())),
315            version: env!("CARGO_PKG_VERSION").to_string(),
316        }
317    }
318
319    /// Resolve the session slot for a `thread_id`, creating it if absent.
320    ///
321    /// An empty `thread_id` resolves to the most recently active session (the
322    /// compatibility path for tools — and older clients — that don't send one).
323    /// Marks the slot as most-recently-used and evicts the LRU session when over
324    /// [`MAX_SESSIONS`].
325    async fn session_for(&self, thread_id: &str) -> SharedBashState {
326        let mut reg = self.sessions.lock().await;
327        let key = if thread_id.is_empty() {
328            reg.last_active.clone().unwrap_or_else(|| "default".to_string())
329        } else {
330            thread_id.to_string()
331        };
332
333        // Evict the LRU session if adding a brand-new key would exceed the cap.
334        if !reg.slots.contains_key(&key) && reg.slots.len() >= MAX_SESSIONS {
335            if let Some(victim) = reg
336                .last_used
337                .iter()
338                .filter(|(k, _)| **k != key)
339                .min_by_key(|(_, t)| **t)
340                .map(|(k, _)| k.clone())
341            {
342                reg.slots.remove(&victim);
343                reg.last_used.remove(&victim);
344                if reg.last_active.as_deref() == Some(victim.as_str()) {
345                    reg.last_active = None;
346                }
347                warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
348            }
349        }
350
351        let slot =
352            reg.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
353        reg.last_used.insert(key.clone(), Instant::now());
354        if !thread_id.is_empty() {
355            reg.last_active = Some(key);
356        }
357        slot
358    }
359
360    /// The most recently active session slot, without creating one. Used by
361    /// session-agnostic surfaces (e.g. the handoff prompt).
362    async fn active_slot(&self) -> Option<SharedBashState> {
363        let reg = self.sessions.lock().await;
364        reg.last_active.as_ref().and_then(|key| reg.slots.get(key).cloned())
365    }
366}
367
368/// `ServerHandler` implementation
369impl ServerHandler for WinxService {
370    fn get_info(&self) -> ServerInfo {
371        ServerInfo::new(
372            ServerCapabilities::builder()
373                .enable_tools()
374                .enable_resources()
375                .enable_prompts()
376                .build(),
377        )
378        .with_server_info(
379            Implementation::new("winx-mcp-server", self.version.clone())
380                .with_title("Winx High-Performance MCP"),
381        )
382        .with_protocol_version(ProtocolVersion::V_2024_11_05)
383        .with_instructions(
384                "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
385        )
386    }
387
388    async fn list_tools(
389        &self,
390        _request: Option<PaginatedRequestParams>,
391        _context: RequestContext<RoleServer>,
392    ) -> Result<ListToolsResult, McpError> {
393        Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
394    }
395
396    async fn list_resources(
397        &self,
398        _param: Option<PaginatedRequestParams>,
399        _context: RequestContext<RoleServer>,
400    ) -> Result<ListResourcesResult, McpError> {
401        Ok(ListResourcesResult {
402            resources: vec![Annotated {
403                raw: RawResource {
404                    uri: "file://readme".into(),
405                    name: "README".into(),
406                    description: Some("Project README documentation".into()),
407                    mime_type: Some("text/markdown".into()),
408                    size: None,
409                    title: None,
410                    icons: None,
411                    meta: None,
412                },
413                annotations: None,
414            }],
415            next_cursor: None,
416            meta: None,
417        })
418    }
419
420    async fn list_prompts(
421        &self,
422        _request: Option<PaginatedRequestParams>,
423        _context: RequestContext<RoleServer>,
424    ) -> Result<ListPromptsResult, McpError> {
425        Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
426    }
427
428    async fn get_prompt(
429        &self,
430        request: GetPromptRequestParams,
431        _context: RequestContext<RoleServer>,
432    ) -> Result<GetPromptResult, McpError> {
433        if request.name != "KnowledgeTransfer" {
434            return Err(McpError::invalid_request(
435                format!("Unknown prompt: {}", request.name),
436                None,
437            ));
438        }
439
440        let text = self.knowledge_transfer_prompt_text().await;
441
442        Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
443            .with_description("Knowledge transfer handoff prompt"))
444    }
445
446    async fn read_resource(
447        &self,
448        param: ReadResourceRequestParams,
449        _context: RequestContext<RoleServer>,
450    ) -> Result<ReadResourceResult, McpError> {
451        let content = match param.uri.as_ref() {
452            "file://readme" => match tokio::fs::read_to_string("README.md").await {
453                Ok(content) => vec![ResourceContents::text(content, param.uri.clone())],
454                Err(_) => vec![ResourceContents::text(
455                    "README.md not found".to_string(),
456                    param.uri.clone(),
457                )],
458            },
459            _ => {
460                return Err(McpError::invalid_request(
461                    format!("Unknown resource URI: {}", param.uri),
462                    None,
463                ));
464            }
465        };
466
467        Ok(ReadResourceResult::new(content))
468    }
469
470    async fn call_tool(
471        &self,
472        param: CallToolRequestParams,
473        _context: RequestContext<RoleServer>,
474    ) -> Result<CallToolResult, McpError> {
475        let tool = param.name.to_string();
476        let args_value = param.arguments.map(Value::Object);
477        // Audit trail: one structured line per tool call, including the outcome
478        // and wall-clock. Successes were previously silent — only errors logged —
479        // which made debugging remote (ChatGPT) sessions guesswork.
480        let summary = audit_summary(&tool, args_value.as_ref());
481        let started = std::time::Instant::now();
482
483        let result = match tool.as_str() {
484            "Initialize" => self.handle_initialize(args_value).await,
485            "BashCommand" => self.handle_bash_command(args_value).await,
486            "ReadFiles" => self.handle_read_files(args_value).await,
487            "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
488            "ContextSave" => self.handle_context_save(args_value).await,
489            "ReadImage" => self.handle_read_image(args_value).await,
490            _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
491        };
492
493        let ms = started.elapsed().as_millis();
494        match &result {
495            Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
496            Err(error) => warn!(tool = %tool, ms, "tool call error — {summary}: {}", error.message),
497        }
498        result
499    }
500}
501
502/// Build a short, non-sensitive audit summary of a tool call's arguments.
503fn audit_summary(tool: &str, args: Option<&Value>) -> String {
504    let Some(args) = args else {
505        return "(no args)".to_string();
506    };
507    let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
508    let clip = |text: String| text.chars().take(100).collect::<String>();
509    match tool {
510        "BashCommand" => {
511            let action = args.get("action_json");
512            let cmd = action
513                .and_then(|a| a.get("command"))
514                .and_then(Value::as_str)
515                .or_else(|| args.get("command").and_then(Value::as_str));
516            if let Some(cmd) = cmd {
517                format!("cmd={:?}", clip(cmd.to_string()))
518            } else {
519                let kind =
520                    action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
521                format!("action={kind}")
522            }
523        }
524        "FileWriteOrEdit" | "ReadImage" => format!("path={}", s("file_path")),
525        "ReadFiles" => {
526            format!(
527                "files={}",
528                args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
529            )
530        }
531        "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
532        "ContextSave" => format!("id={}", s("id")),
533        _ => String::new(),
534    }
535}
536
537impl WinxService {
538    async fn knowledge_transfer_prompt_text(&self) -> String {
539        let mut text = String::from(
540            "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",
541        );
542
543        let state_snapshot = if let Some(slot) = self.active_slot().await {
544            let guard = slot.lock().await;
545            guard.as_ref().map(|state| {
546                let whitelist = state
547                    .whitelist_for_overwrite
548                    .iter()
549                    .take(12)
550                    .map(|(path, data)| {
551                        format!(
552                            "- {} ({:.1}% read, {} lines)",
553                            path,
554                            data.get_percentage_read(),
555                            data.total_lines
556                        )
557                    })
558                    .collect::<Vec<_>>();
559                (
560                    state.current_thread_id.clone(),
561                    state.workspace_root.clone(),
562                    state.cwd.clone(),
563                    state.mode.to_string(),
564                    whitelist,
565                    state.whitelist_for_overwrite.len(),
566                )
567            })
568        } else {
569            None
570        };
571
572        let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
573            state_snapshot
574        else {
575            text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
576            return text;
577        };
578
579        let _ = writeln!(
580            text,
581            "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
582            workspace_root.display(),
583            cwd.display()
584        );
585
586        if !whitelist.is_empty() {
587            text.push_str("\n# Recently readable files\n");
588            text.push_str(&whitelist.join("\n"));
589            text.push('\n');
590        }
591
592        let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
593        if !active_files.is_empty() {
594            text.push_str("\n# Active files by Winx usage\n");
595            for file in active_files.iter().take(12) {
596                let _ = writeln!(text, "- {file}");
597            }
598        }
599
600        if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
601            let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
602            let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
603        }
604
605        append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
606        append_command_section(
607            &mut text,
608            "Git diff stat",
609            &workspace_root,
610            ["diff", "--stat", "HEAD"],
611        );
612
613        // Sections the ContextSave `description` should contain, tailored to the
614        // mode: architect produces a plan (no edits), the others produce a status
615        // + pending-issues handoff (wcgw parity: WCGW_KT vs ARCHITECT_KT).
616        let sections = if mode == "architect" {
617            "\n# Sections for the ContextSave description (architect mode)\n\
618             - `# Objective` — project and task objective.\n\
619             - `# All user instructions` — everything the user asked, verbatim.\n\
620             - `# Designed plan` — the plan you designed, in detail.\n\
621             - Provide all relevant file paths so the next agent can resume; err toward more.\n"
622        } else {
623            "\n# Sections for the ContextSave description\n\
624             - `# Objective` — project and task objective.\n\
625             - `# All user instructions` — everything the user asked, verbatim.\n\
626             - `# Current status` — what's already done (not what's left).\n\
627             - `# Pending issues with snippets` — verbatim errors/tracebacks/commands; be verbose.\n\
628             - `# Build and development instructions` — how to build/run/test; leave empty if unknown.\n\
629             - Provide all relevant file paths so the next agent can resume; err toward more.\n"
630        };
631        text.push_str(sections);
632
633        text.push_str(
634            "\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",
635        );
636
637        text
638    }
639
640    async fn persist_state(&self, slot: &SharedBashState) {
641        let guard = slot.lock().await;
642        if let Some(state) = guard.as_ref() {
643            if let Err(error) = state.save_state_to_disk() {
644                warn!("Failed to persist bash state: {}", error);
645            }
646        }
647    }
648
649    /// Deserialize `args` into `T`, retrying once after JSON-decoding any string
650    /// field that is itself an encoded object/array. LLMs sometimes send a nested
651    /// param (e.g. `code_writer_config`) as a JSON string instead of an object;
652    /// wcgw applies the same leniency in its tool dispatch.
653    fn lenient_from_value<T: serde::de::DeserializeOwned>(
654        args: Value,
655    ) -> Result<T, serde_json::Error> {
656        match serde_json::from_value::<T>(args.clone()) {
657            Ok(value) => Ok(value),
658            Err(first_err) => {
659                let Value::Object(mut map) = args else {
660                    return Err(first_err);
661                };
662                let mut changed = false;
663                for value in map.values_mut() {
664                    if let Value::String(text) = value {
665                        if let Ok(parsed) = serde_json::from_str::<Value>(text) {
666                            if parsed.is_object() || parsed.is_array() {
667                                *value = parsed;
668                                changed = true;
669                            }
670                        }
671                    }
672                }
673                if changed {
674                    serde_json::from_value::<T>(Value::Object(map))
675                } else {
676                    Err(first_err)
677                }
678            }
679        }
680    }
681
682    async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
683        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
684        let mut initialize: Initialize = Self::lenient_from_value(args).map_err(|e| {
685            McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
686        })?;
687
688        // Resolve the session key. A first_call may omit thread_id; generate one
689        // here and write it back so the handler and the registry agree on the id.
690        let mut thread_id = normalize_thread_id(&initialize.thread_id);
691        if thread_id.is_empty() {
692            thread_id = generate_thread_id();
693            initialize.thread_id.clone_from(&thread_id);
694        }
695        let slot = self.session_for(&thread_id).await;
696
697        match crate::tools::initialize::handle_tool_call(&slot, initialize).await {
698            Ok(result) => {
699                self.persist_state(&slot).await;
700                Ok(CallToolResult::success(vec![Content::text(result)]))
701            }
702            Err(e) => Err(to_mcp_error("Initialize", &e)),
703        }
704    }
705
706    async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
707        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
708        let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
709            McpError::invalid_request(
710                format!(
711                    "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
712                ),
713                None,
714            )
715        })?;
716
717        let slot = self.session_for(&normalize_thread_id(&bash_command.thread_id)).await;
718        match crate::tools::bash_command::handle_tool_call(&slot, bash_command).await {
719            Ok(output) => {
720                self.persist_state(&slot).await;
721                Ok(CallToolResult::success(vec![Content::text(output)]))
722            }
723            Err(e) => Err(to_mcp_error("BashCommand", &e)),
724        }
725    }
726
727    async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
728        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
729        let read_files: ReadFiles = Self::lenient_from_value(args).map_err(|e| {
730            McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
731        })?;
732
733        let slot = self.session_for(&normalize_thread_id(&read_files.thread_id)).await;
734        match crate::tools::read_files::handle_tool_call(&slot, read_files).await {
735            Ok(result) => {
736                self.persist_state(&slot).await;
737                Ok(CallToolResult::success(vec![Content::text(result)]))
738            }
739            Err(e) => Err(to_mcp_error("ReadFiles", &e)),
740        }
741    }
742
743    async fn handle_file_write_or_edit(
744        &self,
745        args: Option<Value>,
746    ) -> Result<CallToolResult, McpError> {
747        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
748        let file_write_or_edit: FileWriteOrEdit = Self::lenient_from_value(args).map_err(|e| {
749            McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
750        })?;
751
752        let slot = self.session_for(&normalize_thread_id(&file_write_or_edit.thread_id)).await;
753        match crate::tools::file_write_or_edit::handle_tool_call(&slot, file_write_or_edit).await {
754            Ok(result) => {
755                self.persist_state(&slot).await;
756                Ok(CallToolResult::success(vec![Content::text(result)]))
757            }
758            Err(e) => Err(to_mcp_error("FileWriteOrEdit", &e)),
759        }
760    }
761
762    async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
763        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
764        let context_save: ContextSave = Self::lenient_from_value(args).map_err(|e| {
765            McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
766        })?;
767
768        let slot = self.session_for(&normalize_thread_id(&context_save.thread_id)).await;
769        match crate::tools::context_save::handle_tool_call(&slot, context_save).await {
770            Ok(result) => {
771                self.persist_state(&slot).await;
772                Ok(CallToolResult::success(vec![Content::text(result)]))
773            }
774            Err(e) => Err(to_mcp_error("ContextSave", &e)),
775        }
776    }
777
778    async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
779        let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
780        let read_image: ReadImage = Self::lenient_from_value(args).map_err(|e| {
781            McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
782        })?;
783
784        let slot = self.session_for(&normalize_thread_id(&read_image.thread_id)).await;
785        match crate::tools::read_image::handle_tool_call(&slot, read_image).await {
786            Ok((mime_type, base64_data)) => {
787                self.persist_state(&slot).await;
788                // Return a real image content block (not base64 as text) so the
789                // model can actually see the image. rmcp's `Content::image`
790                // takes (data, mime_type).
791                Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
792            }
793            Err(e) => Err(to_mcp_error("ReadImage", &e)),
794        }
795    }
796}
797
798/// Create and start the Winx MCP server
799pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
800    info!("Starting Winx MCP Server");
801    let service = WinxService::new();
802    let server = service.serve(stdio()).await?;
803    server.waiting().await?;
804    Ok(())
805}
806
807#[cfg(test)]
808mod session_registry_tests {
809    use super::*;
810
811    #[tokio::test]
812    async fn distinct_threads_get_distinct_sessions() {
813        let svc = WinxService::new();
814        let a = svc.session_for("thread_a").await;
815        let b = svc.session_for("thread_b").await;
816        // Two thread_ids must own two separate slots — the whole point of the
817        // registry: thread B never executes in thread A's shell.
818        assert!(!Arc::ptr_eq(&a, &b));
819        // Same id round-trips to the same slot.
820        let a2 = svc.session_for("thread_a").await;
821        assert!(Arc::ptr_eq(&a, &a2));
822    }
823
824    #[tokio::test]
825    async fn empty_thread_id_falls_back_to_last_active() {
826        let svc = WinxService::new();
827        let _a = svc.session_for("thread_a").await;
828        let b = svc.session_for("thread_b").await; // now last-active
829                                                   // A tool call without a thread_id reuses the most recently active slot.
830        let fallback = svc.session_for("").await;
831        assert!(Arc::ptr_eq(&b, &fallback));
832        assert!(svc.active_slot().await.is_some());
833    }
834
835    #[tokio::test]
836    async fn lru_eviction_caps_live_sessions() {
837        let svc = WinxService::new();
838        for i in 0..(MAX_SESSIONS + 5) {
839            let _ = svc.session_for(&format!("t{i}")).await;
840        }
841        let reg = svc.sessions.lock().await;
842        assert!(reg.slots.len() <= MAX_SESSIONS, "session count {} over cap", reg.slots.len());
843    }
844}