1use rmcp::{
5 model::{
6 Annotated, CallToolRequestParams, CallToolResult, Content, GetPromptRequestParams,
7 GetPromptResult, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
8 PaginatedRequestParams, Prompt, PromptMessage, PromptMessageRole, ProtocolVersion,
9 RawResource, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
10 ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
11 },
12 service::{RequestContext, RoleServer},
13 transport::stdio,
14 ErrorData as McpError, ServerHandler, ServiceExt,
15};
16use schemars::schema_for;
17use serde_json::Value;
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::Path;
21use std::process::Command;
22use std::sync::{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
35fn 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
68pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
70
71fn 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
276const MAX_SESSIONS: usize = 32;
280
281#[derive(Default)]
286struct SessionRegistry {
287 slots: HashMap<String, SharedBashState>,
288 last_used: HashMap<String, Instant>,
290 last_active: Option<String>,
293}
294
295#[derive(Clone)]
297pub struct WinxService {
298 sessions: Arc<Mutex<SessionRegistry>>,
299 pub version: String,
301}
302
303impl Default for WinxService {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309impl WinxService {
310 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 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 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 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
368impl 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 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
502fn 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 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 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 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 Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
792 }
793 Err(e) => Err(to_mcp_error("ReadImage", &e)),
794 }
795 }
796}
797
798pub 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 assert!(!Arc::ptr_eq(&a, &b));
819 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; 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}