Skip to main content

winx_code_agent/server/
handler.rs

1use std::fmt::Write as _;
2
3use axum::http::request::Parts;
4use rmcp::{
5    model::{
6        CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, CancelTaskParams,
7        GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskResult,
8        Icon, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
9        PaginatedRequestParams, PromptMessage, ProtocolVersion, ReadResourceRequestParams,
10        ReadResourceResponse, ReadResourceResult, Resource, ResourceContents, Role,
11        ServerCapabilities, ServerInfo, TaskStatus, Tool, UpdateTaskParams,
12    },
13    service::{NotificationContext, RequestContext, RoleServer},
14    ErrorData as McpError, ServerHandler,
15};
16use sha2::{Digest, Sha256};
17
18use super::catalog::{server_icon_data_uri, winx_prompts, winx_tools, winx_tools_for_policy};
19use super::outcomes;
20use super::principal::{
21    conversation_identity_from_context, principal_from_context, scope_tool_request,
22    session_affinity_from_context, task_belongs_to_principal, RequestScope,
23};
24use super::WinxService;
25use crate::runtime::ShellActionOptions;
26use crate::types::{BashCommand, BashWaitPolicy};
27
28pub(crate) const COMPACT_BASH_OUTPUT_EXTENSION: &str = "io.winx/compact-bash-output";
29const ADAPTIVE_TASK_INLINE_SECONDS: f32 = 2.0;
30const RETURN_EARLY_DEFAULT_SECONDS: f32 = 0.25;
31const RETURN_EARLY_MAX_SECONDS: f32 = 5.0;
32const SYNCHRONOUS_MAX_SECONDS: f32 = 60.0;
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35enum BashTaskRoute {
36    Synchronous,
37    Adaptive,
38    Immediate,
39}
40
41fn bash_task_route(
42    client_tasks: bool,
43    generation_bound_runtime: bool,
44    eligible: bool,
45    policy: BashWaitPolicy,
46) -> BashTaskRoute {
47    if !client_tasks || !generation_bound_runtime || !eligible {
48        return BashTaskRoute::Synchronous;
49    }
50    match policy {
51        BashWaitPolicy::Adaptive => BashTaskRoute::Adaptive,
52        BashWaitPolicy::UntilComplete => BashTaskRoute::Immediate,
53        BashWaitPolicy::ReturnEarly => BashTaskRoute::Synchronous,
54    }
55}
56
57fn adaptive_action_options(compact_output: bool) -> ShellActionOptions {
58    ShellActionOptions {
59        compact_output,
60        // Adaptive promotion is already backed by a Task reservation. Require
61        // the effective guardian epoch to be checked live before this first
62        // action so a recreated guardian cannot launch an untracked process
63        // under a stale adapter cache.
64        require_generation_binding: true,
65        ..ShellActionOptions::default()
66    }
67}
68
69/// One structured `winx::usage` event per tool call. Only metadata is logged —
70/// never tool arguments, command text, or file contents (secrets/PII).
71struct UsageEvent {
72    tool: String,
73    action: String,
74    ws: String,
75    principal: String,
76    thread_id: String,
77    request_id: String,
78    client_name: String,
79    client_version: String,
80    protocol: String,
81    client_session: String,
82    conversation_source: String,
83    conversation_id: String,
84    workspace_id: String,
85    workspace_coherence: String,
86    batch_items: usize,
87    worker_limit: usize,
88    started: std::time::Instant,
89}
90
91#[derive(Default)]
92struct UsageResultMetadata<'a> {
93    error_code: &'a str,
94    source_kind: &'a str,
95    payload_bytes: u64,
96    source_bytes: u64,
97    image_transcoded: bool,
98    image_deduplicated: bool,
99    temporary_session_files: u64,
100    temporary_session_bytes: u64,
101    temporary_over_budget: bool,
102}
103
104fn usage_result_metadata(result: Option<&CallToolResult>) -> UsageResultMetadata<'_> {
105    let structured = result.and_then(|result| result.structured_content.as_ref());
106    let data = structured.and_then(|structured| structured.get("data"));
107    let domain_data = data.and_then(|data| data.get("result")).or(data).or(structured);
108    let temporary = data.and_then(|data| data.get("temporary_artifact_budget")).or_else(|| {
109        domain_data.filter(|data| {
110            data.get("temporary_artifact_cleanup_required")
111                .and_then(serde_json::Value::as_bool)
112                .unwrap_or(false)
113        })
114    });
115    UsageResultMetadata {
116        error_code: structured
117            .and_then(|structured| structured.get("errorCode"))
118            .and_then(serde_json::Value::as_str)
119            .unwrap_or_default(),
120        source_kind: structured
121            .and_then(|structured| structured.get("sourceKind"))
122            .or_else(|| domain_data.and_then(|data| data.get("source_kind")))
123            .and_then(serde_json::Value::as_str)
124            .unwrap_or_default(),
125        payload_bytes: structured
126            .and_then(|structured| structured.get("payloadBytes"))
127            .or_else(|| domain_data.and_then(|data| data.get("payload_bytes")))
128            .or_else(|| domain_data.and_then(|data| data.get("delivered_bytes")))
129            .and_then(serde_json::Value::as_u64)
130            .unwrap_or(0),
131        source_bytes: domain_data
132            .and_then(|data| data.get("source_bytes"))
133            .and_then(serde_json::Value::as_u64)
134            .unwrap_or(0),
135        image_transcoded: domain_data
136            .and_then(|data| data.get("transcoded"))
137            .and_then(serde_json::Value::as_bool)
138            .unwrap_or(false),
139        image_deduplicated: domain_data
140            .and_then(|data| data.get("deduplicated"))
141            .and_then(serde_json::Value::as_bool)
142            .unwrap_or(false),
143        temporary_session_files: temporary
144            .and_then(|temporary| temporary.get("session_files"))
145            .and_then(serde_json::Value::as_u64)
146            .unwrap_or(0),
147        temporary_session_bytes: temporary
148            .and_then(|temporary| temporary.get("session_bytes"))
149            .and_then(serde_json::Value::as_u64)
150            .unwrap_or(0),
151        temporary_over_budget: temporary
152            .and_then(|temporary| temporary.get("over_budget"))
153            .and_then(serde_json::Value::as_bool)
154            .or_else(|| {
155                temporary
156                    .and_then(|temporary| temporary.get("temporary_artifact_cleanup_required"))
157                    .and_then(serde_json::Value::as_bool)
158            })
159            .unwrap_or(false),
160    }
161}
162
163impl UsageEvent {
164    fn start(
165        request: &CallToolRequestParams,
166        scope: &RequestScope,
167        context: &RequestContext<RoleServer>,
168    ) -> Self {
169        let arguments = request.arguments.as_ref();
170        let thread_id = arguments
171            .and_then(|arguments| arguments.get("thread_id"))
172            .and_then(serde_json::Value::as_str)
173            .map(|thread_id| scope.unscope_text(thread_id))
174            .unwrap_or_default();
175        let action =
176            if request.name == "BashCommand" { bash_action(arguments) } else { String::new() };
177        let ws = if request.name == "Initialize" {
178            arguments
179                .and_then(|arguments| arguments.get("any_workspace_path"))
180                .and_then(serde_json::Value::as_str)
181                .unwrap_or_default()
182                .to_string()
183        } else {
184            String::new()
185        };
186        let workspace = if request.name == "Initialize" {
187            arguments
188                .and_then(|arguments| arguments.get("any_workspace_path"))
189                .and_then(serde_json::Value::as_str)
190        } else {
191            arguments
192                .and_then(|arguments| arguments.get("workspace_root"))
193                .and_then(serde_json::Value::as_str)
194        }
195        .unwrap_or_default();
196        let workspace_id =
197            if workspace.is_empty() { String::new() } else { short_fingerprint("w", workspace) };
198        let client = context.client_info();
199        let client_name =
200            client.as_ref().map_or("unknown", |client| client.name.as_str()).to_string();
201        let client_version =
202            client.as_ref().map_or("unknown", |client| client.version.as_str()).to_string();
203        let protocol = context
204            .protocol_version()
205            .map_or_else(|| "unknown".to_string(), |version| version.to_string());
206        let request_id = context
207            .extensions
208            .get::<Parts>()
209            .and_then(|parts| parts.extensions.get::<crate::http_server::RequestCorrelation>())
210            .map_or_else(
211                || {
212                    serde_json::to_string(&context.id)
213                        .map_or_else(|_| "unknown".to_string(), |id| short_fingerprint("r", &id))
214                },
215                |correlation| correlation.as_str().to_string(),
216            );
217        let client_session = context
218            .extensions
219            .get::<Parts>()
220            .and_then(|parts| parts.headers.get("mcp-session-id"))
221            .and_then(|value| value.to_str().ok())
222            .map_or_else(|| "stateless".to_string(), |id| short_fingerprint("s", id));
223        let (conversation_source, conversation_id) = conversation_telemetry(context);
224        let batch_items = match request.name.as_ref() {
225            "ReadFiles" => arguments
226                .and_then(|arguments| arguments.get("file_paths"))
227                .and_then(serde_json::Value::as_array)
228                .map_or(0, Vec::len),
229            "MultiFileEdit" => arguments
230                .and_then(|arguments| arguments.get("files"))
231                .and_then(serde_json::Value::as_array)
232                .map_or(0, Vec::len),
233            _ => 0,
234        };
235        let worker_limit = if request.name == "ReadFiles" {
236            crate::tools::read_files::configured_parallelism()
237        } else {
238            0
239        };
240        Self {
241            tool: request.name.to_string(),
242            action,
243            ws,
244            principal: scope
245                .principal()
246                .map_or_else(|| "local".to_string(), |principal| principal.name().to_string()),
247            thread_id,
248            request_id,
249            client_name,
250            client_version,
251            protocol,
252            client_session,
253            conversation_source,
254            conversation_id,
255            workspace_id,
256            workspace_coherence: "unchecked".to_string(),
257            batch_items,
258            worker_limit,
259            started: std::time::Instant::now(),
260        }
261    }
262
263    fn set_workspace_coherence(&mut self, value: &str) {
264        self.workspace_coherence = value.to_string();
265    }
266
267    fn emit(
268        &self,
269        outcome: &str,
270        result_status: &str,
271        response_bytes: usize,
272        result: Option<&CallToolResult>,
273    ) {
274        if self.tool == "Initialize" {
275            let data = result
276                .and_then(|result| result.structured_content.as_ref())
277                .and_then(|structured| structured.get("data"));
278            let initialize_transition = data
279                .and_then(|data| data.get("initialize_transition"))
280                .and_then(serde_json::Value::as_str)
281                .unwrap_or_default();
282            let initialize_reused = data
283                .and_then(|data| data.get("initialize_reused"))
284                .and_then(serde_json::Value::as_bool)
285                .unwrap_or(false);
286            let initialize_response_mode = data
287                .and_then(|data| data.get("initialize_response_mode"))
288                .and_then(serde_json::Value::as_str)
289                .unwrap_or_default();
290            let context_bytes = data
291                .and_then(|data| data.get("context_bytes"))
292                .and_then(serde_json::Value::as_u64)
293                .unwrap_or(0);
294            let guidelines_bytes = data
295                .and_then(|data| data.get("guidelines_bytes"))
296                .and_then(serde_json::Value::as_u64)
297                .unwrap_or(0);
298            let initial_files_count = data
299                .and_then(|data| data.get("initial_files_count"))
300                .and_then(serde_json::Value::as_u64)
301                .unwrap_or(0);
302            let code_writer_policy_strength = data
303                .and_then(|data| data.get("code_writer_policy_strength"))
304                .and_then(serde_json::Value::as_str)
305                .unwrap_or_default();
306            let shell_spawners_present = data
307                .and_then(|data| data.get("shell_spawners_present"))
308                .and_then(serde_json::Value::as_bool)
309                .unwrap_or(false);
310            tracing::info!(
311                target: "winx::usage",
312                event = "tool_call",
313                tool = %self.tool,
314                action = %self.action,
315                ws = %self.ws,
316                principal = %self.principal,
317                thread_id = %self.thread_id,
318                request_id = %self.request_id,
319                client_name = %self.client_name,
320                client_version = %self.client_version,
321                protocol = %self.protocol,
322                client_session = %self.client_session,
323                conversation_source = %self.conversation_source,
324                conversation_id = %self.conversation_id,
325                workspace_id = %self.workspace_id,
326                workspace_coherence = %self.workspace_coherence,
327                batch_items = self.batch_items,
328                worker_limit = self.worker_limit,
329                initialize_transition,
330                initialize_reused,
331                initialize_response_mode,
332                context_bytes,
333                guidelines_bytes,
334                initial_files_count,
335                code_writer_policy_strength,
336                shell_spawners_present,
337                result_status,
338                response_bytes,
339                duration_ms =
340                    u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
341                outcome,
342                "tool call"
343            );
344            return;
345        }
346
347        self.emit_non_initialize(outcome, result_status, response_bytes, result);
348    }
349
350    fn emit_non_initialize(
351        &self,
352        outcome: &str,
353        result_status: &str,
354        response_bytes: usize,
355        result: Option<&CallToolResult>,
356    ) {
357        let metadata = usage_result_metadata(result);
358        tracing::info!(
359            target: "winx::usage",
360            event = "tool_call",
361            tool = %self.tool,
362            action = %self.action,
363            ws = %self.ws,
364            principal = %self.principal,
365            thread_id = %self.thread_id,
366            request_id = %self.request_id,
367            client_name = %self.client_name,
368            client_version = %self.client_version,
369            protocol = %self.protocol,
370            client_session = %self.client_session,
371            conversation_source = %self.conversation_source,
372            conversation_id = %self.conversation_id,
373            workspace_id = %self.workspace_id,
374            workspace_coherence = %self.workspace_coherence,
375            batch_items = self.batch_items,
376            worker_limit = self.worker_limit,
377            error_code = metadata.error_code,
378            source_kind = metadata.source_kind,
379            payload_bytes = metadata.payload_bytes,
380            source_bytes = metadata.source_bytes,
381            image_transcoded = metadata.image_transcoded,
382            image_deduplicated = metadata.image_deduplicated,
383            temporary_session_files = metadata.temporary_session_files,
384            temporary_session_bytes = metadata.temporary_session_bytes,
385            temporary_over_budget = metadata.temporary_over_budget,
386            result_status,
387            response_bytes,
388            duration_ms = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
389            outcome,
390            "tool call"
391        );
392    }
393}
394
395fn short_fingerprint(prefix: &str, value: &str) -> String {
396    let digest = Sha256::digest(value.as_bytes());
397    let mut output = format!("{prefix}_");
398    for byte in &digest[..8] {
399        let _ = write!(output, "{byte:02x}");
400    }
401    output
402}
403
404fn conversation_telemetry(context: &RequestContext<RoleServer>) -> (String, String) {
405    let Some(parts) = context.extensions.get::<Parts>() else {
406        return ("none".to_string(), String::new());
407    };
408    for (header, source) in
409        [("mcp-session-id", "mcp_session"), ("x-winx-conversation-id", "gateway_header")]
410    {
411        if let Some(value) = parts
412            .headers
413            .get(header)
414            .and_then(|value| value.to_str().ok())
415            .map(str::trim)
416            .filter(|value| !value.is_empty())
417        {
418            return (source.to_string(), short_fingerprint("c", value));
419        }
420    }
421    ("none".to_string(), String::new())
422}
423
424/// Emit the cache fields required by MCP 2026-07-28 without changing the wire
425/// shape for clients that negotiated an older protocol revision.
426fn cache_hints_for_protocol(
427    protocol_version: Option<ProtocolVersion>,
428    cache_scope: CacheScope,
429) -> (Option<u64>, Option<CacheScope>) {
430    if protocol_version.is_some_and(|version| version >= ProtocolVersion::V_2026_07_28) {
431        (Some(0), Some(cache_scope))
432    } else {
433        (None, None)
434    }
435}
436
437fn supports_compact_bash_output(context: &RequestContext<RoleServer>) -> bool {
438    context.client_capabilities().is_some_and(|capabilities| {
439        capabilities
440            .extensions
441            .as_ref()
442            .is_some_and(|extensions| extensions.contains_key(COMPACT_BASH_OUTPUT_EXTENSION))
443    })
444}
445
446fn request_requires_bash_capability(request: &CallToolRequestParams) -> bool {
447    matches!(request.name.as_ref(), "FileWriteOrEdit" | "MultiFileEdit")
448        && request
449            .arguments
450            .as_ref()
451            .and_then(|arguments| arguments.get("verify_command"))
452            .is_some_and(|command| !command.is_null())
453}
454
455fn normalized_wait_request(
456    mut request: CallToolRequestParams,
457    policy: BashWaitPolicy,
458    task_inline: bool,
459) -> Result<CallToolRequestParams, McpError> {
460    let mut arguments = request
461        .arguments
462        .clone()
463        .ok_or_else(|| McpError::invalid_request("Missing BashCommand arguments", None))?;
464    let requested = arguments
465        .get("wait_for_seconds")
466        .cloned()
467        .map(serde_json::from_value::<f32>)
468        .transpose()
469        .map_err(|error| {
470            McpError::invalid_request(format!("Invalid wait_for_seconds: {error}"), None)
471        })?;
472    let (default, maximum) = match (policy, task_inline) {
473        (BashWaitPolicy::Adaptive, true) => {
474            (ADAPTIVE_TASK_INLINE_SECONDS, ADAPTIVE_TASK_INLINE_SECONDS)
475        }
476        (BashWaitPolicy::ReturnEarly, _) => {
477            (RETURN_EARLY_DEFAULT_SECONDS, RETURN_EARLY_MAX_SECONDS)
478        }
479        (BashWaitPolicy::Adaptive, false) => (15.0, SYNCHRONOUS_MAX_SECONDS),
480        (BashWaitPolicy::UntilComplete, false) => {
481            (SYNCHRONOUS_MAX_SECONDS, SYNCHRONOUS_MAX_SECONDS)
482        }
483        (BashWaitPolicy::UntilComplete, true) => unreachable!("until_complete starts a Task"),
484    };
485    arguments.insert(
486        "wait_for_seconds".to_string(),
487        serde_json::Value::from(requested.unwrap_or(default).clamp(0.0, maximum)),
488    );
489    request.arguments = Some(arguments);
490    Ok(request)
491}
492
493/// Classify a `BashCommand` call by action kind (`command`, `status_check`,
494/// `send_text`, ...) without touching the command text itself. Mirrors the
495/// lenient forms accepted by the `BashCommand` deserializer: a typed
496/// `action_json` object, a JSON-encoded string, a bare command string, or
497/// legacy shorthand keys at the top level.
498fn bash_action(arguments: Option<&rmcp::model::JsonObject>) -> String {
499    use serde_json::Value;
500    const KINDS: [&str; 7] = [
501        "command",
502        "status_check",
503        "send_text",
504        "send_specials",
505        "send_ascii",
506        "screen",
507        "wait_for_turn",
508    ];
509    let Some(arguments) = arguments else {
510        return String::new();
511    };
512    let parsed;
513    let action = match arguments.get("action_json") {
514        Some(Value::Object(object)) => object,
515        Some(Value::String(text)) => {
516            match serde_json::from_str::<Value>(&text.replace('\n', " ")) {
517                Ok(Value::Object(object)) => {
518                    parsed = object;
519                    &parsed
520                }
521                // A non-object string is treated as a bare command downstream.
522                _ => return "command".to_string(),
523            }
524        }
525        // Legacy shorthand: the argument object itself is the action.
526        _ => arguments,
527    };
528    if let Some(kind) = action.get("type").and_then(Value::as_str) {
529        return kind.to_string();
530    }
531    KINDS.iter().find(|kind| action.contains_key(**kind)).map_or("?", |kind| kind).to_string()
532}
533
534#[allow(clippy::unused_async_trait_impl)] // rmcp's trait requires these async signatures
535impl ServerHandler for WinxService {
536    fn get_info(&self) -> ServerInfo {
537        let mut capabilities = ServerCapabilities::builder()
538            .enable_tools()
539            .enable_resources()
540            .enable_prompts()
541            .enable_tasks()
542            .build();
543        capabilities
544            .extensions
545            .get_or_insert_default()
546            .insert(COMPACT_BASH_OUTPUT_EXTENSION.to_string(), rmcp::model::JsonObject::new());
547        ServerInfo::new(capabilities)
548            .with_server_info(
549                Implementation::new("winx-mcp-server", self.version.clone())
550                    .with_title("Winx High-Performance MCP")
551                    .with_icons(vec![Icon::new(server_icon_data_uri())
552                        .with_mime_type("image/png")
553                        .with_sizes(vec!["96x96".to_string()])]),
554            )
555            .with_protocol_version(ProtocolVersion::V_2026_07_28)
556            .with_instructions(crate::utils::orchestration::server_instructions())
557    }
558
559    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
560        self.initialize_from_client_roots(context).await;
561    }
562
563    async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) {
564        // A changed Roots list is only used as a retry if Winx is still
565        // uninitialized. Never switch an active shell's workspace implicitly.
566        self.initialize_from_client_roots(context).await;
567    }
568
569    fn get_tool(&self, name: &str) -> Option<Tool> {
570        winx_tools().into_iter().find(|tool| tool.name == name)
571    }
572
573    async fn get_task(
574        &self,
575        request: GetTaskParams,
576        context: RequestContext<RoleServer>,
577    ) -> Result<GetTaskResult, McpError> {
578        let principal = principal_from_context(&context);
579        if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
580            return Err(McpError::invalid_request(
581                format!("Unknown or expired task: {}", request.task_id),
582                None,
583            ));
584        }
585        let mut tasks = self.tasks.lock().await;
586        let entry = tasks.get(&request.task_id).ok_or_else(|| {
587            McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
588        })?;
589        Ok(GetTaskResult::new(entry.detailed()))
590    }
591
592    async fn update_task(
593        &self,
594        request: UpdateTaskParams,
595        context: RequestContext<RoleServer>,
596    ) -> Result<(), McpError> {
597        let principal = principal_from_context(&context);
598        if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
599            return Err(McpError::invalid_request(
600                format!("Unknown or expired task: {}", request.task_id),
601                None,
602            ));
603        }
604        let mut tasks = self.tasks.lock().await;
605        tasks.get(&request.task_id).ok_or_else(|| {
606            McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
607        })?;
608        Ok(())
609    }
610
611    async fn cancel_task(
612        &self,
613        request: CancelTaskParams,
614        context: RequestContext<RoleServer>,
615    ) -> Result<(), McpError> {
616        let principal = principal_from_context(&context);
617        if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
618            return Err(McpError::invalid_request(
619                format!("Unknown or expired task: {}", request.task_id),
620                None,
621            ));
622        }
623        let (abort_handle, thread_id, mut execution_token, execution_control) = {
624            let mut tasks = self.tasks.lock().await;
625            let entry = tasks.get_mut(&request.task_id).ok_or_else(|| {
626                McpError::invalid_request(
627                    format!("Unknown or expired task: {}", request.task_id),
628                    None,
629                )
630            })?;
631            if entry.task.status != TaskStatus::Working
632                && entry.task.status != TaskStatus::InputRequired
633            {
634                return Err(McpError::invalid_request(
635                    format!("Task {} is already terminal", request.task_id),
636                    None,
637                ));
638            }
639            entry.request_cancel();
640            let thread_id = entry.thread_id.clone();
641            let execution_token = entry.execution_token();
642            let execution_control = entry.execution_control();
643            // Before a generation exists the worker must stay alive long
644            // enough to publish it and interrupt that exact process. Once it
645            // exists, aborting the polling worker is safe.
646            let abort_handle = execution_token.as_ref().and(entry.abort_handle.take());
647            entry.finish(TaskStatus::Cancelled, Some("Cancelled by client".to_string()), None);
648            (abort_handle, thread_id, execution_token, execution_control)
649        };
650        if let Some(abort_handle) = abort_handle {
651            abort_handle.abort();
652        }
653        if execution_token.is_none() {
654            self.cancel_pending_task_action(&thread_id, &request.task_id).await;
655            execution_token = tokio::time::timeout(
656                std::time::Duration::from_millis(500),
657                execution_control.wait_for_execution(),
658            )
659            .await
660            .ok()
661            .flatten();
662        }
663        self.interrupt_task_execution(&thread_id, execution_token).await;
664        Ok(())
665    }
666
667    async fn list_tools(
668        &self,
669        _request: Option<PaginatedRequestParams>,
670        ctx: RequestContext<RoleServer>,
671    ) -> Result<ListToolsResult, McpError> {
672        let principal = principal_from_context(&ctx);
673        let (tools, scope) = principal.as_ref().map_or_else(
674            || (winx_tools(), CacheScope::Public),
675            |principal| {
676                let policy = principal.tool_policy();
677                let scope = if policy == crate::tool_policy::ToolPolicy::default() {
678                    CacheScope::Public
679                } else {
680                    CacheScope::Private
681                };
682                (winx_tools_for_policy(policy), scope)
683            },
684        );
685        let (ttl_ms, cache_scope) = cache_hints_for_protocol(ctx.protocol_version(), scope);
686        let mut result = ListToolsResult::with_all_items(tools);
687        result.ttl_ms = ttl_ms;
688        result.cache_scope = cache_scope;
689        Ok(result)
690    }
691
692    async fn list_resources(
693        &self,
694        _request: Option<PaginatedRequestParams>,
695        ctx: RequestContext<RoleServer>,
696    ) -> Result<ListResourcesResult, McpError> {
697        let (ttl_ms, cache_scope) =
698            cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
699        let mut result =
700            ListResourcesResult::with_all_items(vec![Resource::new("file://readme", "README")
701                .with_description("Project README documentation")
702                .with_mime_type("text/markdown")]);
703        result.ttl_ms = ttl_ms;
704        result.cache_scope = cache_scope;
705        Ok(result)
706    }
707
708    async fn list_prompts(
709        &self,
710        _request: Option<PaginatedRequestParams>,
711        ctx: RequestContext<RoleServer>,
712    ) -> Result<ListPromptsResult, McpError> {
713        let (ttl_ms, cache_scope) =
714            cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
715        let mut result = ListPromptsResult::with_all_items(winx_prompts());
716        result.ttl_ms = ttl_ms;
717        result.cache_scope = cache_scope;
718        Ok(result)
719    }
720
721    async fn get_prompt(
722        &self,
723        request: GetPromptRequestParams,
724        context: RequestContext<RoleServer>,
725    ) -> Result<GetPromptResponse, McpError> {
726        if request.name != "KnowledgeTransfer" {
727            return Err(McpError::invalid_request(
728                format!("Unknown prompt: {}", request.name),
729                None,
730            ));
731        }
732
733        let principal = principal_from_context(&context);
734        let session_prefix = principal.as_ref().map(crate::config::HttpPrincipal::session_prefix);
735        let text = crate::utils::redact::redact(
736            &self.knowledge_transfer_prompt_text(session_prefix.as_deref()).await,
737        )
738        .into_owned();
739        Ok(GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)])
740            .with_description("Knowledge transfer handoff prompt")
741            .into())
742    }
743
744    async fn read_resource(
745        &self,
746        request: ReadResourceRequestParams,
747        ctx: RequestContext<RoleServer>,
748    ) -> Result<ReadResourceResponse, McpError> {
749        let content = match request.uri.as_ref() {
750            "file://readme" => match tokio::fs::read_to_string("README.md").await {
751                Ok(content) => vec![ResourceContents::text(
752                    crate::utils::redact::redact(&content).into_owned(),
753                    request.uri.clone(),
754                )],
755                Err(_) => vec![ResourceContents::text(
756                    "README.md not found".to_string(),
757                    request.uri.clone(),
758                )],
759            },
760            _ => {
761                return Err(McpError::invalid_request(
762                    format!("Unknown resource URI: {}", request.uri),
763                    None,
764                ));
765            }
766        };
767        let (ttl_ms, cache_scope) =
768            cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Private);
769        let mut result = ReadResourceResult::new(content);
770        result.ttl_ms = ttl_ms;
771        result.cache_scope = cache_scope;
772        Ok(result.into())
773    }
774
775    #[allow(clippy::too_many_lines)] // request scoping, negotiated routing, execution, and telemetry
776    async fn call_tool(
777        &self,
778        request: CallToolRequestParams,
779        context: RequestContext<RoleServer>,
780    ) -> Result<CallToolResponse, McpError> {
781        let principal = principal_from_context(&context);
782        if principal
783            .as_ref()
784            .is_some_and(|principal| !principal.tool_policy().allows(request.name.as_ref()))
785        {
786            return Err(McpError::invalid_request(
787                format!("Tool is not available for this principal: {}", request.name),
788                None,
789            ));
790        }
791        if request_requires_bash_capability(&request)
792            && principal
793                .as_ref()
794                .is_some_and(|principal| !principal.tool_policy().allows("BashCommand"))
795        {
796            return Err(McpError::invalid_request(
797                "verify_command requires BashCommand in this principal's tool policy",
798                None,
799            ));
800        }
801        let affinity = session_affinity_from_context(&context);
802        let conversation_identity = conversation_identity_from_context(&context);
803        let (request, scope) =
804            scope_tool_request(request, principal, affinity, conversation_identity.as_deref())
805                .map_err(|error| {
806                    McpError::invalid_request(format!("Cannot scope remote request: {error}"), None)
807                })?;
808        let mut usage = UsageEvent::start(&request, &scope, &context);
809        match self
810            .validate_workspace_coherence(
811                &request,
812                &scope,
813                affinity,
814                conversation_identity.as_deref(),
815            )
816            .await
817        {
818            Ok(coherence) => usage.set_workspace_coherence(coherence.as_str()),
819            Err(error) => {
820                usage.set_workspace_coherence("rejected");
821                let arguments = request.arguments.clone().map(serde_json::Value::Object);
822                let mut result =
823                    outcomes::tool_failure(request.name.as_ref(), &error, arguments.as_ref())?;
824                scope.unscope_result(&mut result);
825                let status = outcomes::result_status(&result);
826                usage.emit(
827                    "tool_error",
828                    &status,
829                    outcomes::result_size_bytes(&result),
830                    Some(&result),
831                );
832                return Ok(result.into());
833            }
834        }
835        let compact_bash_output = supports_compact_bash_output(&context);
836        let supports_tasks =
837            context.client_capabilities().is_some_and(|capabilities| capabilities.supports_tasks());
838        let wait_policy = if request.name == "BashCommand" {
839            Some(Self::bash_wait_policy(&request)?)
840        } else {
841            None
842        };
843        if wait_policy == Some(BashWaitPolicy::UntilComplete) {
844            let parsed_bash = request.arguments.clone().and_then(|arguments| {
845                serde_json::from_value::<BashCommand>(serde_json::Value::Object(arguments)).ok()
846            });
847            if let Some(bash) = parsed_bash.as_ref() {
848                let is_foreground_command = matches!(
849                    bash.action_json,
850                    crate::types::BashCommandAction::Command { is_background: false, .. }
851                );
852                if !is_foreground_command {
853                    let action = match &bash.action_json {
854                        crate::types::BashCommandAction::Command {
855                            is_background: true, ..
856                        } => "background_command",
857                        crate::types::BashCommandAction::Command { .. } => "foreground_command",
858                        crate::types::BashCommandAction::StatusCheck { .. } => "status_check",
859                        crate::types::BashCommandAction::SendText { .. } => "send_text",
860                        crate::types::BashCommandAction::SendSpecials { .. } => "send_specials",
861                        crate::types::BashCommandAction::SendAscii { .. } => "send_ascii",
862                        crate::types::BashCommandAction::Screen { .. } => "screen",
863                        crate::types::BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
864                    };
865                    let error = crate::errors::WinxError::InvalidWaitPolicyForAction {
866                        wait_policy: "until_complete".to_string(),
867                        action: action.to_string(),
868                    };
869                    let arguments = request.arguments.clone().map(serde_json::Value::Object);
870                    let mut result =
871                        outcomes::tool_failure("BashCommand", &error, arguments.as_ref())?;
872                    scope.unscope_result(&mut result);
873                    let status = outcomes::result_status(&result);
874                    usage.emit(
875                        "tool_error",
876                        &status,
877                        outcomes::result_size_bytes(&result),
878                        Some(&result),
879                    );
880                    return Ok(result.into());
881                }
882            }
883        }
884
885        let eligible = Self::bash_task_is_eligible(&request);
886        let generation_bound_runtime = if supports_tasks && eligible {
887            let thread_id = request
888                .arguments
889                .as_ref()
890                .and_then(|arguments| arguments.get("thread_id"))
891                .and_then(serde_json::Value::as_str)
892                .unwrap_or_default();
893            let (slot, session_guard) = self.session_for(thread_id).await;
894            let supported = self
895                .shell_runtime
896                .supports_generation_bound_actions_for(&slot)
897                .await
898                .unwrap_or(false);
899            drop(session_guard);
900            supported
901        } else {
902            false
903        };
904        let route = wait_policy.map_or(BashTaskRoute::Synchronous, |policy| {
905            bash_task_route(supports_tasks, generation_bound_runtime, eligible, policy)
906        });
907
908        match route {
909            BashTaskRoute::Immediate => {
910                let reservation = self.reserve_bash_task(&request, &scope).await?;
911                return match self
912                    .start_reserved_bash_task(
913                        reservation,
914                        request,
915                        scope,
916                        None,
917                        compact_bash_output,
918                    )
919                    .await
920                {
921                    Ok(task) => {
922                        usage.emit("task", "working", 0, None);
923                        Ok(CallToolResponse::Task(task))
924                    }
925                    Err(error) => {
926                        usage.emit("protocol_error", "failed", 0, None);
927                        Err(error)
928                    }
929                };
930            }
931            BashTaskRoute::Adaptive => {
932                let reservation = self.reserve_bash_task(&request, &scope).await?;
933                let task_request = request.clone();
934                let inline_request =
935                    normalized_wait_request(request, BashWaitPolicy::Adaptive, true)?;
936                match self
937                    .execute_tool_call(inline_request, adaptive_action_options(compact_bash_output))
938                    .await
939                {
940                    Ok(execution) if outcomes::result_status(&execution.result) == "running" => {
941                        return match self
942                            .start_reserved_bash_task(
943                                reservation,
944                                task_request,
945                                scope,
946                                Some(execution),
947                                compact_bash_output,
948                            )
949                            .await
950                        {
951                            Ok(task) => {
952                                usage.emit("task", "working", 0, None);
953                                Ok(CallToolResponse::Task(task))
954                            }
955                            Err(error) => {
956                                usage.emit("protocol_error", "failed", 0, None);
957                                Err(error)
958                            }
959                        };
960                    }
961                    Ok(mut execution) => {
962                        self.release_bash_task(&reservation).await;
963                        scope.unscope_result(&mut execution.result);
964                        let status = outcomes::result_status(&execution.result);
965                        let outcome = if execution.result.is_error == Some(true) {
966                            "tool_error"
967                        } else {
968                            "ok"
969                        };
970                        usage.emit(
971                            outcome,
972                            &status,
973                            outcomes::result_size_bytes(&execution.result),
974                            Some(&execution.result),
975                        );
976                        return Ok(execution.result.into());
977                    }
978                    Err(mut error) => {
979                        self.release_bash_task(&reservation).await;
980                        scope.unscope_error(&mut error);
981                        usage.emit("protocol_error", "failed", 0, None);
982                        return Err(error);
983                    }
984                }
985            }
986            BashTaskRoute::Synchronous => {}
987        }
988
989        let request = match wait_policy {
990            Some(policy) => normalized_wait_request(request, policy, false)?,
991            None => request,
992        };
993        match self
994            .execute_tool_call(
995                request,
996                ShellActionOptions {
997                    compact_output: compact_bash_output,
998                    ..ShellActionOptions::default()
999                },
1000            )
1001            .await
1002        {
1003            Ok(mut execution) => {
1004                let result = &mut execution.result;
1005                scope.unscope_result(result);
1006                let status = outcomes::result_status(result);
1007                let outcome = if result.is_error == Some(true) { "tool_error" } else { "ok" };
1008                usage.emit(outcome, &status, outcomes::result_size_bytes(result), Some(result));
1009                Ok(execution.result.into())
1010            }
1011            Err(mut error) => {
1012                scope.unscope_error(&mut error);
1013                usage.emit("protocol_error", "failed", 0, None);
1014                Err(error)
1015            }
1016        }
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn cache_hints_follow_negotiated_protocol_version() {
1026        assert_eq!(
1027            cache_hints_for_protocol(Some(ProtocolVersion::V_2026_07_28), CacheScope::Private,),
1028            (Some(0), Some(CacheScope::Private))
1029        );
1030        assert_eq!(
1031            cache_hints_for_protocol(Some(ProtocolVersion::V_2026_07_28), CacheScope::Public),
1032            (Some(0), Some(CacheScope::Public))
1033        );
1034        assert_eq!(
1035            cache_hints_for_protocol(Some(ProtocolVersion::V_2025_11_25), CacheScope::Private,),
1036            (None, None)
1037        );
1038        assert_eq!(cache_hints_for_protocol(None, CacheScope::Private), (None, None));
1039    }
1040
1041    #[test]
1042    fn guardian_1_4_uses_synchronous_policy_fallback_even_for_task_clients() {
1043        assert_eq!(
1044            bash_task_route(true, false, true, BashWaitPolicy::Adaptive),
1045            BashTaskRoute::Synchronous
1046        );
1047        assert_eq!(
1048            bash_task_route(true, false, true, BashWaitPolicy::UntilComplete),
1049            BashTaskRoute::Synchronous
1050        );
1051    }
1052
1053    #[test]
1054    fn task_routing_requires_client_runtime_and_foreground_eligibility() {
1055        assert_eq!(
1056            bash_task_route(true, true, true, BashWaitPolicy::Adaptive),
1057            BashTaskRoute::Adaptive
1058        );
1059        assert_eq!(
1060            bash_task_route(true, true, true, BashWaitPolicy::UntilComplete),
1061            BashTaskRoute::Immediate
1062        );
1063        assert_eq!(
1064            bash_task_route(true, true, true, BashWaitPolicy::ReturnEarly),
1065            BashTaskRoute::Synchronous
1066        );
1067        assert_eq!(
1068            bash_task_route(false, true, true, BashWaitPolicy::UntilComplete),
1069            BashTaskRoute::Synchronous
1070        );
1071        assert_eq!(
1072            bash_task_route(true, true, false, BashWaitPolicy::UntilComplete),
1073            BashTaskRoute::Synchronous
1074        );
1075    }
1076
1077    #[test]
1078    fn adaptive_action_requires_live_generation_binding() {
1079        let options = adaptive_action_options(true);
1080        assert!(options.compact_output);
1081        assert!(options.require_generation_binding);
1082    }
1083
1084    #[test]
1085    fn usage_metadata_reads_image_code_map_and_temp_envelopes() {
1086        let result_with = |structured| {
1087            let mut result = CallToolResult::success(Vec::new());
1088            result.structured_content = Some(structured);
1089            result
1090        };
1091        let image = result_with(serde_json::json!({
1092            "data": {"result": {
1093                "source_bytes": 17_000_000,
1094                "delivered_bytes": 2_000_000,
1095                "transcoded": true,
1096                "deduplicated": false
1097            }}
1098        }));
1099        let metadata = usage_result_metadata(Some(&image));
1100        assert_eq!(metadata.source_bytes, 17_000_000);
1101        assert_eq!(metadata.payload_bytes, 2_000_000);
1102        assert!(metadata.image_transcoded);
1103        assert!(!metadata.image_deduplicated);
1104
1105        let code_map = result_with(serde_json::json!({
1106            "source_kind": "canonical",
1107            "payload_bytes": 12_345
1108        }));
1109        let metadata = usage_result_metadata(Some(&code_map));
1110        assert_eq!(metadata.source_kind, "canonical");
1111        assert_eq!(metadata.payload_bytes, 12_345);
1112
1113        let temporary = result_with(serde_json::json!({
1114            "data": {
1115                "session_files": 129,
1116                "session_bytes": 4096,
1117                "temporary_artifact_cleanup_required": true
1118            }
1119        }));
1120        let metadata = usage_result_metadata(Some(&temporary));
1121        assert_eq!(metadata.temporary_session_files, 129);
1122        assert_eq!(metadata.temporary_session_bytes, 4096);
1123        assert!(metadata.temporary_over_budget);
1124    }
1125
1126    #[test]
1127    #[allow(clippy::expect_used, clippy::float_cmp)]
1128    fn synchronous_wait_policies_have_explicit_upper_bounds() {
1129        let request = |wait_for_seconds: Option<f32>| {
1130            let mut arguments = serde_json::Map::new();
1131            arguments.insert("command".to_string(), serde_json::Value::String("pwd".to_string()));
1132            if let Some(wait) = wait_for_seconds {
1133                arguments.insert("wait_for_seconds".to_string(), serde_json::json!(wait));
1134            }
1135            CallToolRequestParams::new("BashCommand").with_arguments(arguments)
1136        };
1137        let normalized = |policy, wait| {
1138            normalized_wait_request(request(wait), policy, false)
1139                .expect("valid wait request")
1140                .arguments
1141                .and_then(|arguments| arguments.get("wait_for_seconds").cloned())
1142                .and_then(|value| value.as_f64())
1143                .expect("normalized wait")
1144        };
1145
1146        assert_eq!(normalized(BashWaitPolicy::Adaptive, Some(500.0)), 60.0);
1147        assert_eq!(normalized(BashWaitPolicy::UntilComplete, None), 60.0);
1148        assert_eq!(normalized(BashWaitPolicy::ReturnEarly, Some(500.0)), 5.0);
1149        assert_eq!(normalized(BashWaitPolicy::ReturnEarly, None), 0.25);
1150    }
1151}