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 require_generation_binding: true,
65 ..ShellActionOptions::default()
66 }
67}
68
69struct 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
424fn 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
493fn 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 _ => return "command".to_string(),
523 }
524 }
525 _ => 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)] impl 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 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 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)] 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}