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::atomic::{AtomicUsize, Ordering};
23use std::sync::{Arc, OnceLock};
24use std::time::Instant;
25use tokio::sync::Mutex;
26use tracing::{info, warn};
27
28use crate::errors::WinxError;
29use crate::state::bash_state::generate_thread_id;
30use crate::state::BashState;
31use crate::types::{
32 normalize_thread_id, BashCommand, CodeMap, ContextSave, FileWriteOrEdit, Initialize,
33 MultiFileEdit, ReadFiles, ReadImage, UndoEdit,
34};
35
36fn to_mcp_error(tool: &str, err: &WinxError) -> McpError {
43 let msg = format!("{tool} failed: {err}");
44 match err {
48 WinxError::BashStateNotInitialized
50 | WinxError::CommandNotAllowed(_)
51 | WinxError::PathSecurityError { .. }
52 | WinxError::ThreadIdMismatch(_)
53 | WinxError::ParameterValidationError { .. }
54 | WinxError::MissingParameterError { .. }
55 | WinxError::NullValueError { .. }
56 | WinxError::ArgumentParseError(_)
57 | WinxError::JsonParseError(_)
58 | WinxError::DeserializationError(_)
59 | WinxError::WorkspacePathError(_)
60 | WinxError::InvalidInput(_)
61 | WinxError::ParseError(_)
62 | WinxError::FileAccessError { .. }
63 | WinxError::RecoverableSuggestionError { .. }
64 | WinxError::SearchReplaceSyntaxError(_)
65 | WinxError::SearchReplaceSyntaxErrorDetailed { .. }
66 | WinxError::SearchBlockNotFound(_)
67 | WinxError::SearchBlockAmbiguous { .. }
68 | WinxError::FileTooLarge { .. }
69 | WinxError::InteractiveCommandDetected { .. }
70 | WinxError::CommandAlreadyRunning { .. } => McpError::invalid_request(msg, None),
71 WinxError::ShellInitializationError(_)
73 | WinxError::BashStateLockError(_)
74 | WinxError::CommandExecutionError(_)
75 | WinxError::SerializationError(_)
76 | WinxError::FileWriteError { .. }
77 | WinxError::DataLoadingError(_)
78 | WinxError::ContextSaveError(_)
79 | WinxError::CommandTimeout { .. }
80 | WinxError::ProcessCleanupError { .. }
81 | WinxError::BufferOverflow { .. }
82 | WinxError::SessionRecoveryError { .. }
83 | WinxError::ResourceAllocationError { .. }
84 | WinxError::IoError(_)
85 | WinxError::ConfigurationError(_)
86 | WinxError::FileError(_) => McpError::internal_error(msg, None),
87 }
88}
89
90pub type SharedBashState = Arc<Mutex<Option<BashState>>>;
92
93fn schema_to_input_schema<T: schemars::JsonSchema>() -> Arc<serde_json::Map<String, Value>> {
95 let schema = schema_for!(T);
96 let mut value = serde_json::to_value(schema).unwrap_or(Value::Object(serde_json::Map::new()));
97 strip_schema_titles(&mut value);
102 match value {
103 Value::Object(map) => Arc::new(map),
104 _ => Arc::new(serde_json::Map::new()),
105 }
106}
107
108fn strip_schema_titles(value: &mut Value) {
115 match value {
116 Value::Object(map) => {
117 const SCHEMA_KEYS: &[&str] = &[
118 "type",
119 "$ref",
120 "properties",
121 "items",
122 "additionalProperties",
123 "enum",
124 "const",
125 "anyOf",
126 "allOf",
127 "oneOf",
128 ];
129 if SCHEMA_KEYS.iter().any(|k| map.contains_key(*k)) {
130 map.remove("title");
131 }
132 for child in map.values_mut() {
133 strip_schema_titles(child);
134 }
135 }
136 Value::Array(items) => {
137 for item in items {
138 strip_schema_titles(item);
139 }
140 }
141 _ => {}
142 }
143}
144
145fn mcp_tool<T: schemars::JsonSchema>(
146 name: &'static str,
147 description: &'static str,
148 annotations: ToolAnnotations,
149) -> Tool {
150 Tool::new(name, description, schema_to_input_schema::<T>()).with_annotations(annotations)
151}
152
153const INITIALIZE_DESCRIPTION: &str =
154 "- Always call this at the start of the conversation before using any of the shell tools from wcgw. \
155 - Use `any_workspace_path` to initialize the shell in the appropriate project directory. \
156 - If the user has mentioned a workspace or project root or any other file or folder use it to set `any_workspace_path`. \
157 - If user has mentioned any files use `initial_files_to_read` to read, use absolute paths only (~ allowed) \
158 - By default use mode \"wcgw\" \
159 - In \"code-writer\" mode, set the commands and globs which user asked to set, otherwise use 'all'. \
160 - Use type=\"first_call\" if it's the first call to this tool. \
161 - Use type=\"user_asked_mode_change\" if in a conversation user has asked to change mode. \
162 - Use type=\"reset_shell\" if in a conversation shell is not working after multiple tries. \
163 - Use type=\"user_asked_change_workspace\" if in a conversation user asked to change workspace";
164
165const BASH_COMMAND_DESCRIPTION: &str =
166 "- Execute a bash command. This is stateful (beware with subsequent calls). \
167 - Accepted payloads include action_json with an explicit type, action_json shorthand such as {\"command\":\"pwd\"}, or top-level shorthand such as {\"command\":\"pwd\"}. \
168 - Status of the command and the current working directory will always be returned at the end. \
169 - The first or the last line might be `(...truncated)` if the output is too long. \
170 - Always run `pwd` if you get any file or directory not found error to make sure you're not lost. \
171 - Do not run bg commands using \"&\", instead use this tool. \
172 - You must not use echo/cat to read/write files, use ReadFiles/FileWriteOrEdit \
173 - In order to check status of previous command, use `status_check` with empty command argument. \
174 - Only command is allowed to run at a time. You need to wait for any previous command to finish before running a new one. \
175 - Programs don't hang easily, so most likely explanation for no output is usually that the program is still running, and you need to check status again. \
176 - Do not send Ctrl-c before checking for status till 10 minutes or whatever is appropriate for the program to finish. \
177 - Only run long running commands in background. Each background command is run in a new non-reusable shell. \
178 - On running a bg command you'll get a bg command id that you should use to get status or interact. \
179 - Piloting an interactive full-screen TUI (the `claude` CLI, vim, htop, fzf, a REPL)? Run it in the background, then drive it with these two actions: \
180 - `screen` ({\"screen\":true,\"bg_command_id\":\"...\",\"lines\":N,\"diff\":true}) returns a STABLE snapshot of the live terminal screen (cursor moves, redraws, alternate-screen and synchronized-output already applied; ANSI stripped), with the cursor position in the header. Use this to read the current frame — unlike `status_check`, it never stacks redraw generations and never waits. Pass \"diff\":true to get back ONLY the lines that changed since your last `screen` look (large token savings when polling a TUI frame-by-frame; first look or a big change still returns the full frame). \
181 - `wait_for_turn` ({\"wait_for_turn\":true,\"bg_command_id\":\"...\",\"recognizer\":\"auto|claude|codex|antigravity|generic\",\"quiet_ms\":600,\"timeout_seconds\":30}) BLOCKS until the TUI finishes its turn and is ready for input, then returns the stable snapshot plus the detected state (busy / awaiting_input / awaiting_approval). Typical REPL loop: run the app in bg -> wait_for_turn -> send_text(submit:true) -> wait_for_turn -> screen, repeat.";
182
183const READ_FILES_DESCRIPTION: &str =
184 "- Read full file content of one or more files. \
185 - Prefer this over reading files with BashCommand (cat/head/tail): the output is token-budgeted and the read is recorded so FileWriteOrEdit can edit the file afterward. \
186 - Do NOT use this for binary files or images — use ReadImage for images. \
187 - Provide absolute paths only (~ allowed) \
188 - Only if the task requires line numbers understanding: \
189 - You may extract a range of lines. E.g., `/path/to/file:1-10` for lines 1-10. You can drop start or end like `/path/to/file:1-` or `/path/to/file:-10`";
190
191const FILE_WRITE_OR_EDIT_DESCRIPTION: &str =
192 "- Writes or edits a file based on the percentage of changes. \
193 - Prefer this over writing/editing files with BashCommand (echo/sed/redirects/heredocs). \
194 - For an edit, the file must have been read first with ReadFiles, otherwise the edit is rejected. \
195 - Use absolute path only (~ allowed). \
196 - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change \
197 - percentage_to_change should be low if mostly new code is to be added. It should be high if a lot of things are to be replaced. \
198 - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks \
199 - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks. \
200 \
201 Instructions for editing files. \
202 # Example \
203 ## Input file \
204 ``` \
205 import numpy as np \
206 from impls import impl1, impl2 \
207 \
208 def hello(): \
209 \"print a greeting\" \
210 print(\"hello\") \
211 \
212 def call_hello(): \
213 \"call hello\" \
214 hello() \
215 print(\"Called\") \
216 impl1() \
217 hello() \
218 impl2() \
219 ``` \
220 ## Edit format on the input file \
221 ``` \
222 <<<<<<< SEARCH \
223 from impls import impl1, impl2 \
224 ======= \
225 from impls import impl1, impl2 \
226 from hello import hello as hello_renamed \
227 >>>>>>> REPLACE \
228 <<<<<<< SEARCH \
229 def hello(): \
230 \"print a greeting\" \
231 print(\"hello\") \
232 ======= \
233 >>>>>>> REPLACE \
234 <<<<<<< SEARCH \
235 def call_hello(): \
236 \"call hello\" \
237 hello() \
238 ======= \
239 def call_hello_renamed(): \
240 \"call hello renamed\" \
241 hello_renamed() \
242 >>>>>>> REPLACE \
243 <<<<<<< SEARCH \
244 impl1() \
245 hello() \
246 impl2() \
247 ======= \
248 impl1() \
249 hello_renamed() \
250 impl2() \
251 >>>>>>> REPLACE \
252 ``` \
253 # *SEARCH/REPLACE block* Rules: \
254 Every \"<<<<<<< SEARCH\" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc. \
255 Including multiple unique *SEARCH/REPLACE* blocks if needed. \
256 Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change. \
257 Keep *SEARCH/REPLACE* blocks concise. \
258 Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. \
259 Include just the changing lines, and a few surrounding lines (0-3 lines) if needed for uniqueness. \
260 Other than for uniqueness, avoid including those lines which do not change in search (and replace) blocks. Target 0-3 non trivial extra lines per block. \
261 Preserve leading spaces and indentations in both SEARCH and REPLACE blocks. \
262 If a short block would match multiple places, anchor it to a line number from ReadFiles instead of padding with context: write the marker as \"<<<<<<< SEARCH @42\" (or a range \"@42-50\") to target that 1-based location. A stale anchor falls back to the normal search, so it never makes a valid edit fail.";
263
264const MULTI_FILE_EDIT_DESCRIPTION: &str =
265 "- Edits SEVERAL files together, all-or-nothing. Use this over multiple FileWriteOrEdit calls when a change spans files and a partial apply would be bad (e.g. rename a symbol across files). \
266 - Every file's edit is validated and computed in memory FIRST; only if ALL succeed is anything written, so a SEARCH that fails to match in the last file leaves the earlier files untouched. \
267 - Each entry has the same fields as FileWriteOrEdit: file_path (absolute, ~ allowed), percentage_to_change, and text_or_search_replace_blocks. Each file must have been read with ReadFiles first. \
268 - Provide 2+ files; for a single file use FileWriteOrEdit. Do not list the same file twice. \
269 - If a write fails mid-batch (rare: disk/permissions), it stops and reports which files were already written; those are not rolled back.";
270
271const UNDO_EDIT_DESCRIPTION: &str =
272 "- Reverts a file to the content it had before the last FileWriteOrEdit/MultiFileEdit you made to it THIS session. \
273 - Use this to back out a wrong edit instead of re-typing the old content. \
274 - Per-file: call it again on the same file to walk further back through its edits (the last ~10 edits per session are kept, in memory only). \
275 - Refused if the file changed on disk since your edit (so an undo never discards newer changes), and a brand-new file's creation cannot be undone (no prior content) - use BashCommand rm for that. \
276 - Provide file_path (absolute, ~ allowed).";
277
278const CONTEXT_SAVE_DESCRIPTION: &str =
279 "Saves provided description and file contents of all the relevant file paths or globs in a single text file. \
280 - Provide random 3 word unqiue id or whatever user provided. \
281 - Leave project path as empty string if no project path";
282
283const CODE_MAP_DESCRIPTION: &str =
284 "- Navigate code structure via tree-sitter - the semantic layer plain grep/rg can't give you. Pick an `operation`: \
285 - operation=\"outline\": map symbols (functions, types, methods, classes, ...). `path` to a FILE returns that file's definitions; `path` to a DIRECTORY (or empty = the whole workspace) returns a relevance-ranked, token-budgeted symbol map across files. Use it instead of reading whole files just to learn their shape. \
286 - operation=\"references\": find where a symbol is defined and referenced (called/used), by name. `name` is required (exact identifier). Counts only real symbol occurrences, never matches inside strings or comments. Output lists definitions first, then references, as `def|ref file:line kind name`. \
287 - Scope either operation with `path` (file or directory; empty = whole workspace); cap with `max_results`. gitignore-aware, workspace-confined, works in every mode. \
288 - 11 languages (rust, js/ts, go, c, c++, java, ruby, c#, php, lua); other files return no symbols. Note: C/C++ grammars tag definitions only, so references reads 0 for `.c`/`.h`/`.cpp`. \
289 - For plain-text/regex search or file discovery, use rg / grep / fd / find via BashCommand.";
290
291static WINX_TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
292static WINX_PROMPTS: OnceLock<Vec<Prompt>> = OnceLock::new();
293
294fn winx_tools() -> Vec<Tool> {
295 WINX_TOOLS.get_or_init(build_winx_tools).clone()
296}
297
298fn build_winx_tools() -> Vec<Tool> {
299 vec![
300 mcp_tool::<Initialize>(
301 "Initialize",
302 INITIALIZE_DESCRIPTION,
303 ToolAnnotations::new().read_only(true).open_world(false),
304 ),
305 mcp_tool::<BashCommand>(
306 "BashCommand",
307 BASH_COMMAND_DESCRIPTION,
308 ToolAnnotations::new().destructive(true).open_world(true),
309 ),
310 mcp_tool::<ReadFiles>(
311 "ReadFiles",
312 READ_FILES_DESCRIPTION,
313 ToolAnnotations::new().read_only(true).open_world(false),
314 ),
315 mcp_tool::<FileWriteOrEdit>(
316 "FileWriteOrEdit",
317 FILE_WRITE_OR_EDIT_DESCRIPTION,
318 ToolAnnotations::new().destructive(true).open_world(false),
319 ),
320 mcp_tool::<MultiFileEdit>(
321 "MultiFileEdit",
322 MULTI_FILE_EDIT_DESCRIPTION,
323 ToolAnnotations::new().destructive(true).open_world(false),
324 ),
325 mcp_tool::<UndoEdit>(
326 "UndoEdit",
327 UNDO_EDIT_DESCRIPTION,
328 ToolAnnotations::new().destructive(true).open_world(false),
329 ),
330 mcp_tool::<ContextSave>(
331 "ContextSave",
332 CONTEXT_SAVE_DESCRIPTION,
333 ToolAnnotations::new().destructive(false).open_world(false),
334 ),
335 mcp_tool::<ReadImage>(
336 "ReadImage",
337 "Read an image from the shell.",
338 ToolAnnotations::new().read_only(true).open_world(false),
339 ),
340 mcp_tool::<CodeMap>(
341 "CodeMap",
342 CODE_MAP_DESCRIPTION,
343 ToolAnnotations::new().read_only(true).open_world(false),
344 ),
345 ]
346}
347
348fn winx_prompts() -> Vec<Prompt> {
349 WINX_PROMPTS
350 .get_or_init(|| {
351 vec![Prompt::new(
352 "KnowledgeTransfer",
353 Some("Summarize current Winx state, workspace context, and handoff notes."),
354 None,
355 )]
356 })
357 .clone()
358}
359
360fn append_command_section<const N: usize>(
361 output: &mut String,
362 title: &str,
363 cwd: &Path,
364 args: [&str; N],
365) {
366 let Ok(command_output) = Command::new("git").args(["-C"]).arg(cwd).args(args).output() else {
367 return;
368 };
369 if !command_output.status.success() {
370 return;
371 }
372
373 let content = String::from_utf8_lossy(&command_output.stdout);
374 if content.trim().is_empty() {
375 return;
376 }
377
378 let _ = writeln!(output, "\n# {title}\n{}", content.trim_end());
379}
380
381const MAX_SESSIONS: usize = 32;
385
386#[derive(Default)]
391struct SessionRegistry {
392 slots: HashMap<String, SharedBashState>,
393 last_used: HashMap<String, Instant>,
395 in_flight: HashMap<String, SessionPin>,
399 last_active: Option<String>,
402}
403
404#[derive(Clone)]
411struct SessionPin {
412 count: Arc<AtomicUsize>,
413}
414
415impl Default for SessionPin {
416 fn default() -> Self {
417 Self { count: Arc::new(AtomicUsize::new(0)) }
418 }
419}
420
421impl SessionPin {
422 fn acquire(&self) -> SessionGuard {
424 self.count.fetch_add(1, Ordering::SeqCst);
425 SessionGuard { count: self.count.clone() }
426 }
427
428 fn is_pinned(&self) -> bool {
430 self.count.load(Ordering::SeqCst) > 0
431 }
432}
433
434struct SessionGuard {
440 count: Arc<AtomicUsize>,
441}
442
443impl Drop for SessionGuard {
444 fn drop(&mut self) {
445 self.count.fetch_sub(1, Ordering::SeqCst);
446 }
447}
448
449#[derive(Clone, Copy, PartialEq, Eq, Debug)]
451pub enum SessionIsolation {
452 Lenient,
455 Strict,
465}
466
467#[derive(Clone)]
469pub struct WinxService {
470 sessions: Arc<Mutex<SessionRegistry>>,
471 pub version: String,
473 isolation: SessionIsolation,
475}
476
477impl Default for WinxService {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483impl WinxService {
484 pub fn new() -> Self {
487 Self::with_isolation(SessionIsolation::Lenient)
488 }
489
490 pub fn with_isolation(isolation: SessionIsolation) -> Self {
493 info!(?isolation, "Creating new WinxService instance");
494 Self {
495 sessions: Arc::new(Mutex::new(SessionRegistry::default())),
496 version: env!("CARGO_PKG_VERSION").to_string(),
497 isolation,
498 }
499 }
500
501 async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
510 let mut reg = self.sessions.lock().await;
511 let key = if thread_id.is_empty() {
512 match self.isolation {
513 SessionIsolation::Lenient => {
515 reg.last_active.clone().unwrap_or_else(|| "default".to_string())
516 }
517 SessionIsolation::Strict => "anonymous".to_string(),
520 }
521 } else {
522 thread_id.to_string()
523 };
524
525 if !reg.slots.contains_key(&key) && reg.slots.len() >= MAX_SESSIONS {
530 let victim = reg
531 .last_used
532 .iter()
533 .filter(|(k, _)| **k != key)
534 .filter(|(k, _)| reg.in_flight.get(k.as_str()).map_or(true, |p| !p.is_pinned()))
535 .min_by_key(|(_, t)| **t)
536 .map(|(k, _)| k.clone());
537 if let Some(victim) = victim {
538 reg.slots.remove(&victim);
539 reg.last_used.remove(&victim);
540 reg.in_flight.remove(&victim);
541 if reg.last_active.as_deref() == Some(victim.as_str()) {
542 reg.last_active = None;
543 }
544 warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
545 } else {
546 warn!(
547 "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
548 );
549 }
550 }
551
552 let slot =
553 reg.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
554 let pin = reg.in_flight.entry(key.clone()).or_default().clone();
555 reg.last_used.insert(key.clone(), Instant::now());
556 if !thread_id.is_empty() {
557 reg.last_active = Some(key);
558 }
559 (slot, pin.acquire())
560 }
561
562 async fn active_slot(&self) -> Option<SharedBashState> {
565 let reg = self.sessions.lock().await;
566 reg.last_active.as_ref().and_then(|key| reg.slots.get(key).cloned())
567 }
568}
569
570impl ServerHandler for WinxService {
572 fn get_info(&self) -> ServerInfo {
573 ServerInfo::new(
574 ServerCapabilities::builder()
575 .enable_tools()
576 .enable_resources()
577 .enable_prompts()
578 .build(),
579 )
580 .with_server_info(
581 Implementation::new("winx-mcp-server", self.version.clone())
582 .with_title("Winx High-Performance MCP"),
583 )
584 .with_protocol_version(ProtocolVersion::V_2025_06_18)
588 .with_instructions(
589 "Winx is a high-performance Rust implementation of MCP tools for shell and file management."
590 )
591 }
592
593 async fn list_tools(
594 &self,
595 _request: Option<PaginatedRequestParams>,
596 _context: RequestContext<RoleServer>,
597 ) -> Result<ListToolsResult, McpError> {
598 Ok(ListToolsResult { tools: winx_tools(), next_cursor: None, meta: None })
599 }
600
601 async fn list_resources(
602 &self,
603 _param: Option<PaginatedRequestParams>,
604 _context: RequestContext<RoleServer>,
605 ) -> Result<ListResourcesResult, McpError> {
606 Ok(ListResourcesResult {
607 resources: vec![Annotated {
608 raw: RawResource {
609 uri: "file://readme".into(),
610 name: "README".into(),
611 description: Some("Project README documentation".into()),
612 mime_type: Some("text/markdown".into()),
613 size: None,
614 title: None,
615 icons: None,
616 meta: None,
617 },
618 annotations: None,
619 }],
620 next_cursor: None,
621 meta: None,
622 })
623 }
624
625 async fn list_prompts(
626 &self,
627 _request: Option<PaginatedRequestParams>,
628 _context: RequestContext<RoleServer>,
629 ) -> Result<ListPromptsResult, McpError> {
630 Ok(ListPromptsResult { prompts: winx_prompts(), next_cursor: None, meta: None })
631 }
632
633 async fn get_prompt(
634 &self,
635 request: GetPromptRequestParams,
636 _context: RequestContext<RoleServer>,
637 ) -> Result<GetPromptResult, McpError> {
638 if request.name != "KnowledgeTransfer" {
639 return Err(McpError::invalid_request(
640 format!("Unknown prompt: {}", request.name),
641 None,
642 ));
643 }
644
645 let text =
648 crate::utils::redact::redact(&self.knowledge_transfer_prompt_text().await).into_owned();
649
650 Ok(GetPromptResult::new(vec![PromptMessage::new_text(PromptMessageRole::User, text)])
651 .with_description("Knowledge transfer handoff prompt"))
652 }
653
654 async fn read_resource(
655 &self,
656 param: ReadResourceRequestParams,
657 _context: RequestContext<RoleServer>,
658 ) -> Result<ReadResourceResult, McpError> {
659 let content = match param.uri.as_ref() {
660 "file://readme" => match tokio::fs::read_to_string("README.md").await {
661 Ok(content) => vec![ResourceContents::text(
662 crate::utils::redact::redact(&content).into_owned(),
663 param.uri.clone(),
664 )],
665 Err(_) => vec![ResourceContents::text(
666 "README.md not found".to_string(),
667 param.uri.clone(),
668 )],
669 },
670 _ => {
671 return Err(McpError::invalid_request(
672 format!("Unknown resource URI: {}", param.uri),
673 None,
674 ));
675 }
676 };
677
678 Ok(ReadResourceResult::new(content))
679 }
680
681 async fn call_tool(
682 &self,
683 param: CallToolRequestParams,
684 _context: RequestContext<RoleServer>,
685 ) -> Result<CallToolResult, McpError> {
686 let tool = param.name.to_string();
687 let args_value = param.arguments.map(Value::Object);
688 let summary = audit_summary(&tool, args_value.as_ref());
692 let started = std::time::Instant::now();
693
694 let result = match tool.as_str() {
695 "Initialize" => self.handle_initialize(args_value).await,
696 "BashCommand" => self.handle_bash_command(args_value).await,
697 "ReadFiles" => self.handle_read_files(args_value).await,
698 "FileWriteOrEdit" => self.handle_file_write_or_edit(args_value).await,
699 "MultiFileEdit" => self.handle_multi_file_edit(args_value).await,
700 "UndoEdit" => self.handle_undo_edit(args_value).await,
701 "ContextSave" => self.handle_context_save(args_value).await,
702 "ReadImage" => self.handle_read_image(args_value).await,
703 "CodeMap" => self.handle_code_map(args_value).await,
704 _ => Err(McpError::invalid_request(format!("Unknown tool: {tool}"), None)),
705 };
706
707 let result = match result {
711 Ok(mut call) => {
712 redact_result(&mut call);
713 Ok(call)
714 }
715 err => err,
716 };
717
718 let ms = started.elapsed().as_millis();
719 match &result {
720 Ok(_) => info!(tool = %tool, ms, "tool call ok — {summary}"),
721 Err(error) => warn!(
722 tool = %tool, ms,
723 "tool call error — {summary}: {}",
724 crate::utils::redact::redact(&error.message)
725 ),
726 }
727 result
728 }
729}
730
731fn audit_summary(tool: &str, args: Option<&Value>) -> String {
733 let Some(args) = args else {
734 return "(no args)".to_string();
735 };
736 let s = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").to_string();
737 let clip = |text: String| text.chars().take(100).collect::<String>();
738 match tool {
739 "BashCommand" => {
740 let action = args.get("action_json");
741 let cmd = action
742 .and_then(|a| a.get("command"))
743 .and_then(Value::as_str)
744 .or_else(|| args.get("command").and_then(Value::as_str));
745 if let Some(cmd) = cmd {
746 format!("cmd={:?}", clip(cmd.to_string()))
747 } else {
748 let kind =
749 action.and_then(|a| a.get("type")).and_then(Value::as_str).unwrap_or("?");
750 format!("action={kind}")
751 }
752 }
753 "FileWriteOrEdit" | "ReadImage" | "UndoEdit" => format!("path={}", s("file_path")),
754 "MultiFileEdit" => {
755 format!("files={}", args.get("files").and_then(Value::as_array).map_or(0, Vec::len))
756 }
757 "ReadFiles" => {
758 format!(
759 "files={}",
760 args.get("file_paths").and_then(Value::as_array).map_or(0, Vec::len)
761 )
762 }
763 "Initialize" => format!("ws={} mode={}", s("any_workspace_path"), s("mode_name")),
764 "ContextSave" => format!("id={}", s("id")),
765 "CodeMap" => format!("op={} path={} name={}", s("operation"), s("path"), s("name")),
766 _ => String::new(),
767 }
768}
769
770fn redact_result(result: &mut CallToolResult) {
774 use rmcp::model::RawContent;
775 for content in &mut result.content {
776 if let RawContent::Text(text) = &mut content.raw {
777 if let std::borrow::Cow::Owned(scrubbed) = crate::utils::redact::redact(&text.text) {
778 text.text = scrubbed;
779 }
780 }
781 }
782 if let Some(structured) = result.structured_content.as_mut() {
783 crate::utils::redact::redact_json(structured);
784 }
785}
786
787impl WinxService {
788 async fn knowledge_transfer_prompt_text(&self) -> String {
789 let mut text = String::from(
790 "Prepare a concise handoff for another agent. Include active objective, current state, important files, changed files, blockers, validation already run, and exact next commands.\n",
791 );
792
793 let state_snapshot = if let Some(slot) = self.active_slot().await {
794 let guard = slot.lock().await;
795 guard.as_ref().map(|state| {
796 let whitelist = state
797 .whitelist_for_overwrite
798 .iter()
799 .take(12)
800 .map(|(path, data)| {
801 format!(
802 "- {} ({:.1}% read, {} lines)",
803 path,
804 data.get_percentage_read(),
805 data.total_lines
806 )
807 })
808 .collect::<Vec<_>>();
809 (
810 state.current_thread_id.clone(),
811 state.workspace_root.clone(),
812 state.cwd.clone(),
813 state.mode.to_string(),
814 whitelist,
815 state.whitelist_for_overwrite.len(),
816 )
817 })
818 } else {
819 None
820 };
821
822 let Some((thread_id, workspace_root, cwd, mode, whitelist, whitelist_count)) =
823 state_snapshot
824 else {
825 text.push_str("\n# Current Winx state\nWinx is not initialized.\n");
826 return text;
827 };
828
829 let _ = writeln!(
830 text,
831 "\n# Current Winx state\nThread: {thread_id}\nWorkspace: {}\nCwd: {}\nMode: {mode}\nWhitelisted files: {whitelist_count}",
832 workspace_root.display(),
833 cwd.display()
834 );
835
836 if !whitelist.is_empty() {
837 text.push_str("\n# Recently readable files\n");
838 text.push_str(&whitelist.join("\n"));
839 text.push('\n');
840 }
841
842 let active_files = crate::utils::workspace_stats::active_files(&workspace_root);
843 if !active_files.is_empty() {
844 text.push_str("\n# Active files by Winx usage\n");
845 for file in active_files.iter().take(12) {
846 let _ = writeln!(text, "- {file}");
847 }
848 }
849
850 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(&workspace_root) {
851 let repo_excerpt = repo_context.lines().take(80).collect::<Vec<_>>().join("\n");
852 let _ = writeln!(text, "\n# Workspace context\n{repo_excerpt}");
853 }
854
855 append_command_section(&mut text, "Git status", &workspace_root, ["status", "--short"]);
856 append_command_section(
857 &mut text,
858 "Git diff stat",
859 &workspace_root,
860 ["diff", "--stat", "HEAD"],
861 );
862
863 let sections = if mode == "architect" {
867 "\n# Sections for the ContextSave description (architect mode)\n\
868 - `# Objective` — project and task objective.\n\
869 - `# All user instructions` — everything the user asked, verbatim.\n\
870 - `# Designed plan` — the plan you designed, in detail.\n\
871 - Provide all relevant file paths so the next agent can resume; err toward more.\n"
872 } else {
873 "\n# Sections for the ContextSave description\n\
874 - `# Objective` — project and task objective.\n\
875 - `# All user instructions` — everything the user asked, verbatim.\n\
876 - `# Current status` — what's already done (not what's left).\n\
877 - `# Pending issues with snippets` — verbatim errors/tracebacks/commands; be verbose.\n\
878 - `# Build and development instructions` — how to build/run/test; leave empty if unknown.\n\
879 - Provide all relevant file paths so the next agent can resume; err toward more.\n"
880 };
881 text.push_str(sections);
882
883 text.push_str(
884 "\n# Handoff checklist\n- State what changed and why.\n- Include files touched and any user-owned dirty work to preserve.\n- Include validation commands already run and their result.\n- Include the next safest command to continue.\n",
885 );
886
887 text
888 }
889
890 async fn persist_state(&self, slot: &SharedBashState) {
891 let guard = slot.lock().await;
892 if let Some(state) = guard.as_ref() {
893 if let Err(error) = state.save_state_to_disk() {
894 warn!("Failed to persist bash state: {}", error);
895 }
896 }
897 }
898
899 fn lenient_from_value<T: serde::de::DeserializeOwned>(
904 args: Value,
905 ) -> Result<T, serde_json::Error> {
906 match serde_json::from_value::<T>(args.clone()) {
907 Ok(value) => Ok(value),
908 Err(first_err) => {
909 let Value::Object(mut map) = args else {
910 return Err(first_err);
911 };
912 let mut changed = false;
913 for value in map.values_mut() {
914 if let Value::String(text) = value {
915 if let Ok(parsed) = serde_json::from_str::<Value>(text) {
916 if parsed.is_object() || parsed.is_array() {
917 *value = parsed;
918 changed = true;
919 }
920 }
921 }
922 }
923 if changed {
924 serde_json::from_value::<T>(Value::Object(map))
925 } else {
926 Err(first_err)
927 }
928 }
929 }
930 }
931
932 async fn handle_initialize(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
933 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
934 let mut initialize: Initialize = Self::lenient_from_value(args).map_err(|e| {
935 McpError::invalid_request(format!("Invalid Initialize parameters: {e}"), None)
936 })?;
937
938 let mut thread_id = normalize_thread_id(&initialize.thread_id);
941 if thread_id.is_empty() {
942 thread_id = generate_thread_id();
943 initialize.thread_id.clone_from(&thread_id);
944 }
945 let (slot, _session_guard) = self.session_for(&thread_id).await;
946
947 match crate::tools::initialize::handle_tool_call(&slot, initialize).await {
948 Ok(result) => {
949 self.persist_state(&slot).await;
950 Ok(CallToolResult::success(vec![Content::text(result)]))
951 }
952 Err(e) => Err(to_mcp_error("Initialize", &e)),
953 }
954 }
955
956 async fn handle_bash_command(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
957 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
958 let bash_command: BashCommand = serde_json::from_value(args).map_err(|e| {
959 McpError::invalid_request(
960 format!(
961 "Invalid BashCommand parameters: {e}. Accepted forms include {{\"action_json\": {{\"command\": \"pwd\"}}}}, {{\"command\": \"pwd\"}}, or {{\"action_json\": {{\"type\": \"status_check\", \"status_check\": true}}}}."
962 ),
963 None,
964 )
965 })?;
966
967 let (slot, _session_guard) =
968 self.session_for(&normalize_thread_id(&bash_command.thread_id)).await;
969 match crate::tools::bash_command::handle_tool_call(&slot, bash_command).await {
970 Ok(output) => {
971 self.persist_state(&slot).await;
972 Ok(CallToolResult::success(vec![Content::text(output)]))
973 }
974 Err(e) => Err(to_mcp_error("BashCommand", &e)),
975 }
976 }
977
978 async fn handle_read_files(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
979 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
980 let read_files: ReadFiles = Self::lenient_from_value(args).map_err(|e| {
981 McpError::invalid_request(format!("Invalid ReadFiles parameters: {e}"), None)
982 })?;
983
984 let (slot, _session_guard) =
985 self.session_for(&normalize_thread_id(&read_files.thread_id)).await;
986 match crate::tools::read_files::handle_tool_call(&slot, read_files).await {
987 Ok(result) => {
988 self.persist_state(&slot).await;
989 Ok(CallToolResult::success(vec![Content::text(result)]))
990 }
991 Err(e) => Err(to_mcp_error("ReadFiles", &e)),
992 }
993 }
994
995 async fn handle_file_write_or_edit(
996 &self,
997 args: Option<Value>,
998 ) -> Result<CallToolResult, McpError> {
999 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1000 let file_write_or_edit: FileWriteOrEdit = Self::lenient_from_value(args).map_err(|e| {
1001 McpError::invalid_request(format!("Invalid FileWriteOrEdit parameters: {e}"), None)
1002 })?;
1003
1004 let (slot, _session_guard) =
1005 self.session_for(&normalize_thread_id(&file_write_or_edit.thread_id)).await;
1006 match crate::tools::file_write_or_edit::handle_tool_call(&slot, file_write_or_edit).await {
1007 Ok(result) => {
1008 self.persist_state(&slot).await;
1009 Ok(CallToolResult::success(vec![Content::text(result)]))
1010 }
1011 Err(e) => Err(to_mcp_error("FileWriteOrEdit", &e)),
1012 }
1013 }
1014
1015 async fn handle_multi_file_edit(
1016 &self,
1017 args: Option<Value>,
1018 ) -> Result<CallToolResult, McpError> {
1019 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1020 let multi: MultiFileEdit = Self::lenient_from_value(args).map_err(|e| {
1021 McpError::invalid_request(format!("Invalid MultiFileEdit parameters: {e}"), None)
1022 })?;
1023
1024 let (slot, _session_guard) = self.session_for(&normalize_thread_id(&multi.thread_id)).await;
1025 match crate::tools::multi_file_edit::handle_tool_call(&slot, multi).await {
1026 Ok(result) => {
1027 self.persist_state(&slot).await;
1028 Ok(CallToolResult::success(vec![Content::text(result)]))
1029 }
1030 Err(e) => Err(to_mcp_error("MultiFileEdit", &e)),
1031 }
1032 }
1033
1034 async fn handle_undo_edit(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1035 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1036 let undo: UndoEdit = Self::lenient_from_value(args).map_err(|e| {
1037 McpError::invalid_request(format!("Invalid UndoEdit parameters: {e}"), None)
1038 })?;
1039
1040 let (slot, _session_guard) = self.session_for(&normalize_thread_id(&undo.thread_id)).await;
1041 match crate::tools::undo_edit::handle_tool_call(&slot, undo).await {
1042 Ok(result) => {
1043 self.persist_state(&slot).await;
1044 Ok(CallToolResult::success(vec![Content::text(result)]))
1045 }
1046 Err(e) => Err(to_mcp_error("UndoEdit", &e)),
1047 }
1048 }
1049
1050 async fn handle_context_save(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1051 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1052 let context_save: ContextSave = Self::lenient_from_value(args).map_err(|e| {
1053 McpError::invalid_request(format!("Invalid ContextSave parameters: {e}"), None)
1054 })?;
1055
1056 let (slot, _session_guard) =
1057 self.session_for(&normalize_thread_id(&context_save.thread_id)).await;
1058 match crate::tools::context_save::handle_tool_call(&slot, context_save).await {
1059 Ok(result) => {
1060 self.persist_state(&slot).await;
1061 Ok(CallToolResult::success(vec![Content::text(result)]))
1062 }
1063 Err(e) => Err(to_mcp_error("ContextSave", &e)),
1064 }
1065 }
1066
1067 async fn handle_read_image(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1068 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1069 let read_image: ReadImage = Self::lenient_from_value(args).map_err(|e| {
1070 McpError::invalid_request(format!("Invalid ReadImage parameters: {e}"), None)
1071 })?;
1072
1073 let (slot, _session_guard) =
1074 self.session_for(&normalize_thread_id(&read_image.thread_id)).await;
1075 match crate::tools::read_image::handle_tool_call(&slot, read_image).await {
1076 Ok((mime_type, base64_data)) => {
1077 self.persist_state(&slot).await;
1078 Ok(CallToolResult::success(vec![Content::image(base64_data, mime_type)]))
1082 }
1083 Err(e) => Err(to_mcp_error("ReadImage", &e)),
1084 }
1085 }
1086
1087 async fn handle_code_map(&self, args: Option<Value>) -> Result<CallToolResult, McpError> {
1088 let args = args.ok_or_else(|| McpError::invalid_request("Missing arguments", None))?;
1089 let code_map: CodeMap = Self::lenient_from_value(args).map_err(|e| {
1090 McpError::invalid_request(format!("Invalid CodeMap parameters: {e}"), None)
1091 })?;
1092
1093 let (slot, _session_guard) =
1095 self.session_for(&normalize_thread_id(&code_map.thread_id)).await;
1096 match crate::tools::code_map::handle_tool_call(&slot, code_map).await {
1097 Ok((text, structured)) => {
1098 let mut result = CallToolResult::success(vec![Content::text(text)]);
1099 result.structured_content = Some(structured);
1100 Ok(result)
1101 }
1102 Err(e) => Err(to_mcp_error("CodeMap", &e)),
1103 }
1104 }
1105}
1106
1107pub async fn start_winx_server() -> Result<(), Box<dyn std::error::Error>> {
1109 info!("Starting Winx MCP Server");
1110 let service = WinxService::new();
1111 let server = service.serve(stdio()).await?;
1112 server.waiting().await?;
1113 Ok(())
1114}
1115
1116#[cfg(test)]
1117mod session_registry_tests {
1118 use super::*;
1119
1120 #[tokio::test]
1121 async fn distinct_threads_get_distinct_sessions() {
1122 let svc = WinxService::new();
1123 let (a, _) = svc.session_for("thread_a").await;
1124 let (b, _) = svc.session_for("thread_b").await;
1125 assert!(!Arc::ptr_eq(&a, &b));
1128 let (a2, _) = svc.session_for("thread_a").await;
1130 assert!(Arc::ptr_eq(&a, &a2));
1131 }
1132
1133 #[tokio::test]
1134 async fn empty_thread_id_falls_back_to_last_active() {
1135 let svc = WinxService::new();
1136 let (_a, _) = svc.session_for("thread_a").await;
1137 let (b, _) = svc.session_for("thread_b").await; let (fallback, _) = svc.session_for("").await;
1140 assert!(Arc::ptr_eq(&b, &fallback));
1141 assert!(svc.active_slot().await.is_some());
1142 }
1143
1144 #[tokio::test]
1145 async fn strict_isolation_empty_thread_id_does_not_steal_active_session() {
1146 let svc = WinxService::with_isolation(SessionIsolation::Strict);
1147 let (a, _) = svc.session_for("thread_a").await; let (anon, _) = svc.session_for("").await;
1149 assert!(!Arc::ptr_eq(&a, &anon));
1151 let (anon2, _) = svc.session_for("").await;
1153 assert!(Arc::ptr_eq(&anon, &anon2));
1154 let (b, _) = svc.session_for("thread_b").await;
1156 assert!(!Arc::ptr_eq(&a, &b));
1157 }
1158
1159 #[tokio::test]
1160 async fn lru_eviction_caps_live_sessions() {
1161 let svc = WinxService::new();
1162 for i in 0..(MAX_SESSIONS + 5) {
1163 let (_, _) = svc.session_for(&format!("t{i}")).await;
1164 }
1165 let reg = svc.sessions.lock().await;
1166 assert!(reg.slots.len() <= MAX_SESSIONS, "session count {} over cap", reg.slots.len());
1167 }
1168
1169 #[tokio::test]
1170 async fn in_flight_session_is_not_evicted() {
1171 let svc = WinxService::new();
1172 let (_keep_slot, _keep_guard) = svc.session_for("keep").await;
1174 for i in 0..(MAX_SESSIONS + 10) {
1177 let (_, _) = svc.session_for(&format!("filler{i}")).await;
1178 }
1179 let reg = svc.sessions.lock().await;
1180 assert!(
1181 reg.slots.contains_key("keep"),
1182 "an in-flight session must survive LRU eviction churn"
1183 );
1184 }
1185}
1186
1187#[cfg(test)]
1188mod schema_tests {
1189 use super::{schema_to_input_schema, strip_schema_titles};
1190 use serde_json::json;
1191
1192 #[test]
1193 fn strips_titles_from_schema_nodes_only() {
1194 let mut v = json!({
1195 "type": "object",
1196 "title": "ShouldGo",
1197 "properties": {
1198 "title": { "type": "string", "title": "InnerGoes" }
1201 }
1202 });
1203 strip_schema_titles(&mut v);
1204 assert!(v.get("title").is_none(), "schema-node title not stripped");
1205 let props = v.get("properties").and_then(serde_json::Value::as_object);
1206 assert!(
1207 props.is_some_and(|p| p.contains_key("title")),
1208 "property key named 'title' must be preserved"
1209 );
1210 assert!(
1211 props.and_then(|p| p.get("title")).and_then(|t| t.get("title")).is_none(),
1212 "inner schema title not stripped"
1213 );
1214 }
1215
1216 #[test]
1217 fn real_tool_schema_carries_no_titles() {
1218 let schema = schema_to_input_schema::<crate::types::Initialize>();
1219 let blob = serde_json::to_string(&*schema).unwrap_or_default();
1220 assert!(!blob.contains("\"title\""), "tool schema still contains titles: {blob}");
1221 }
1222}
1223
1224#[cfg(test)]
1225mod error_mapping_tests {
1226 use super::*;
1227 use rmcp::model::ErrorCode;
1228 use std::path::PathBuf;
1229
1230 fn code_of(err: &WinxError) -> ErrorCode {
1231 to_mcp_error("Tool", err).code
1232 }
1233
1234 #[test]
1235 fn client_caused_errors_map_to_invalid_request() {
1236 assert_eq!(
1238 code_of(&WinxError::RecoverableSuggestionError {
1239 message: "bad arg".into(),
1240 suggestion: "try x".into(),
1241 }),
1242 ErrorCode::INVALID_REQUEST,
1243 );
1244 assert_eq!(
1246 code_of(&WinxError::ParseError("unexpected token".into())),
1247 ErrorCode::INVALID_REQUEST,
1248 );
1249 assert_eq!(
1251 code_of(&WinxError::FileAccessError {
1252 path: PathBuf::from("/nope"),
1253 message: "no such file".into(),
1254 }),
1255 ErrorCode::INVALID_REQUEST,
1256 );
1257 }
1258
1259 #[test]
1260 fn server_caused_errors_stay_internal_error() {
1261 assert_eq!(
1262 code_of(&WinxError::IoError(std::io::Error::other("disk gone"))),
1263 ErrorCode::INTERNAL_ERROR,
1264 );
1265 assert_eq!(
1266 code_of(&WinxError::BashStateLockError("poisoned".into())),
1267 ErrorCode::INTERNAL_ERROR,
1268 );
1269 }
1270}
1271
1272#[cfg(all(test, feature = "loom"))]
1287mod loom_tests {
1288 use loom::sync::atomic::{AtomicUsize, Ordering};
1289 use loom::sync::Arc;
1290
1291 #[derive(Clone)]
1292 struct PinModel {
1293 count: Arc<AtomicUsize>,
1294 }
1295
1296 struct GuardModel {
1297 count: Arc<AtomicUsize>,
1298 }
1299
1300 impl PinModel {
1301 fn new() -> Self {
1302 Self { count: Arc::new(AtomicUsize::new(0)) }
1303 }
1304 fn acquire(&self) -> GuardModel {
1305 self.count.fetch_add(1, Ordering::SeqCst);
1306 GuardModel { count: self.count.clone() }
1307 }
1308 fn is_pinned(&self) -> bool {
1309 self.count.load(Ordering::SeqCst) > 0
1310 }
1311 }
1312
1313 impl Drop for GuardModel {
1314 fn drop(&mut self) {
1315 self.count.fetch_sub(1, Ordering::SeqCst);
1316 }
1317 }
1318
1319 #[test]
1325 fn loom_concurrent_guards_balance_to_zero() {
1326 loom::model(|| {
1327 let pin = PinModel::new();
1328 let p1 = pin.clone();
1329 let p2 = pin.clone();
1330 let h1 = loom::thread::spawn(move || drop(p1.acquire()));
1331 let h2 = loom::thread::spawn(move || drop(p2.acquire()));
1332 assert!(h1.join().is_ok());
1333 assert!(h2.join().is_ok());
1334 assert_eq!(pin.count.load(Ordering::SeqCst), 0, "pin must settle back to 0");
1335 });
1336 }
1337
1338 #[test]
1342 fn loom_live_guard_always_reads_pinned() {
1343 loom::model(|| {
1344 let pin = PinModel::new();
1345 let observer = pin.clone();
1346 let held = pin.acquire();
1347 let worker = pin.clone();
1350 let h = loom::thread::spawn(move || drop(worker.acquire()));
1351 assert!(observer.is_pinned(), "session with a live guard must read pinned");
1352 assert!(h.join().is_ok());
1353 assert!(observer.is_pinned(), "still pinned while the first guard lives");
1354 drop(held);
1355 assert!(!observer.is_pinned(), "all guards gone -> unpinned");
1356 });
1357 }
1358}