1use rmcp::{
2 model::{
3 CacheScope, CallToolRequestParams, CallToolResponse, CancelTaskParams,
4 GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskResult,
5 Icon, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
6 PaginatedRequestParams, PromptMessage, ProtocolVersion, ReadResourceRequestParams,
7 ReadResourceResponse, ReadResourceResult, Resource, ResourceContents, Role,
8 ServerCapabilities, ServerInfo, TaskStatus, Tool, UpdateTaskParams,
9 },
10 service::{NotificationContext, RequestContext, RoleServer},
11 ErrorData as McpError, ServerHandler,
12};
13
14use super::catalog::{server_icon_data_uri, winx_prompts, winx_tools};
15use super::principal::{
16 principal_from_context, scope_tool_request, session_affinity_from_context,
17 task_belongs_to_principal, RequestScope,
18};
19use super::WinxService;
20
21struct UsageEvent {
24 tool: String,
25 action: String,
26 ws: String,
27 principal: String,
28 thread_id: String,
29 started: std::time::Instant,
30}
31
32impl UsageEvent {
33 fn start(request: &CallToolRequestParams, scope: &RequestScope) -> Self {
34 let arguments = request.arguments.as_ref();
35 let thread_id = arguments
36 .and_then(|arguments| arguments.get("thread_id"))
37 .and_then(serde_json::Value::as_str)
38 .map(|thread_id| scope.unscope_text(thread_id))
39 .unwrap_or_default();
40 let action =
41 if request.name == "BashCommand" { bash_action(arguments) } else { String::new() };
42 let ws = if request.name == "Initialize" {
43 arguments
44 .and_then(|arguments| arguments.get("any_workspace_path"))
45 .and_then(serde_json::Value::as_str)
46 .unwrap_or_default()
47 .to_string()
48 } else {
49 String::new()
50 };
51 Self {
52 tool: request.name.to_string(),
53 action,
54 ws,
55 principal: scope
56 .principal()
57 .map_or_else(|| "local".to_string(), |principal| principal.name().to_string()),
58 thread_id,
59 started: std::time::Instant::now(),
60 }
61 }
62
63 fn emit(&self, outcome: &str) {
64 tracing::info!(
65 target: "winx::usage",
66 event = "tool_call",
67 tool = %self.tool,
68 action = %self.action,
69 ws = %self.ws,
70 principal = %self.principal,
71 thread_id = %self.thread_id,
72 duration_ms = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
73 outcome,
74 "tool call"
75 );
76 }
77}
78
79fn cache_hints_for_protocol(
82 protocol_version: Option<ProtocolVersion>,
83 cache_scope: CacheScope,
84) -> (Option<u64>, Option<CacheScope>) {
85 if protocol_version.is_some_and(|version| version >= ProtocolVersion::V_2026_07_28) {
86 (Some(0), Some(cache_scope))
87 } else {
88 (None, None)
89 }
90}
91
92fn bash_action(arguments: Option<&rmcp::model::JsonObject>) -> String {
98 use serde_json::Value;
99 const KINDS: [&str; 7] = [
100 "command",
101 "status_check",
102 "send_text",
103 "send_specials",
104 "send_ascii",
105 "screen",
106 "wait_for_turn",
107 ];
108 let Some(arguments) = arguments else {
109 return String::new();
110 };
111 let parsed;
112 let action = match arguments.get("action_json") {
113 Some(Value::Object(object)) => object,
114 Some(Value::String(text)) => {
115 match serde_json::from_str::<Value>(&text.replace('\n', " ")) {
116 Ok(Value::Object(object)) => {
117 parsed = object;
118 &parsed
119 }
120 _ => return "command".to_string(),
122 }
123 }
124 _ => arguments,
126 };
127 if let Some(kind) = action.get("type").and_then(Value::as_str) {
128 return kind.to_string();
129 }
130 KINDS.iter().find(|kind| action.contains_key(**kind)).map_or("?", |kind| kind).to_string()
131}
132
133impl ServerHandler for WinxService {
134 fn get_info(&self) -> ServerInfo {
135 ServerInfo::new(
136 ServerCapabilities::builder()
137 .enable_tools()
138 .enable_resources()
139 .enable_prompts()
140 .enable_tasks()
141 .build(),
142 )
143 .with_server_info(
144 Implementation::new("winx-mcp-server", self.version.clone())
145 .with_title("Winx High-Performance MCP")
146 .with_icons(vec![Icon::new(server_icon_data_uri())
147 .with_mime_type("image/png")
148 .with_sizes(vec!["96x96".to_string()])]),
149 )
150 .with_protocol_version(ProtocolVersion::V_2026_07_28)
151 .with_instructions(
152 "Winx is a high-performance Rust implementation of MCP tools for shell and file management.",
153 )
154 }
155
156 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
157 self.initialize_from_client_roots(context).await;
158 }
159
160 async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) {
161 self.initialize_from_client_roots(context).await;
164 }
165
166 fn get_tool(&self, name: &str) -> Option<Tool> {
167 winx_tools().into_iter().find(|tool| tool.name == name)
168 }
169
170 async fn get_task(
171 &self,
172 request: GetTaskParams,
173 context: RequestContext<RoleServer>,
174 ) -> Result<GetTaskResult, McpError> {
175 let principal = principal_from_context(&context);
176 if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
177 return Err(McpError::invalid_request(
178 format!("Unknown or expired task: {}", request.task_id),
179 None,
180 ));
181 }
182 let mut tasks = self.tasks.lock().await;
183 let entry = tasks.get(&request.task_id).ok_or_else(|| {
184 McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
185 })?;
186 Ok(GetTaskResult::new(entry.detailed()))
187 }
188
189 async fn update_task(
190 &self,
191 request: UpdateTaskParams,
192 context: RequestContext<RoleServer>,
193 ) -> Result<(), McpError> {
194 let principal = principal_from_context(&context);
195 if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
196 return Err(McpError::invalid_request(
197 format!("Unknown or expired task: {}", request.task_id),
198 None,
199 ));
200 }
201 let mut tasks = self.tasks.lock().await;
202 tasks.get(&request.task_id).ok_or_else(|| {
203 McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
204 })?;
205 Ok(())
206 }
207
208 async fn cancel_task(
209 &self,
210 request: CancelTaskParams,
211 context: RequestContext<RoleServer>,
212 ) -> Result<(), McpError> {
213 let principal = principal_from_context(&context);
214 if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
215 return Err(McpError::invalid_request(
216 format!("Unknown or expired task: {}", request.task_id),
217 None,
218 ));
219 }
220 let (abort_handle, thread_id) = {
221 let mut tasks = self.tasks.lock().await;
222 let entry = tasks.get_mut(&request.task_id).ok_or_else(|| {
223 McpError::invalid_request(
224 format!("Unknown or expired task: {}", request.task_id),
225 None,
226 )
227 })?;
228 if entry.task.status != TaskStatus::Working
229 && entry.task.status != TaskStatus::InputRequired
230 {
231 return Err(McpError::invalid_request(
232 format!("Task {} is already terminal", request.task_id),
233 None,
234 ));
235 }
236 let abort_handle = entry.abort_handle.take();
237 let thread_id = entry.thread_id.clone();
238 entry.finish(TaskStatus::Cancelled, Some("Cancelled by client".to_string()), None);
239 (abort_handle, thread_id)
240 };
241 if let Some(abort_handle) = abort_handle {
242 abort_handle.abort();
243 }
244 self.interrupt_task_thread(&thread_id).await;
245 Ok(())
246 }
247
248 async fn list_tools(
249 &self,
250 _request: Option<PaginatedRequestParams>,
251 ctx: RequestContext<RoleServer>,
252 ) -> Result<ListToolsResult, McpError> {
253 let (ttl_ms, cache_scope) =
254 cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
255 let mut result = ListToolsResult::with_all_items(winx_tools());
256 result.ttl_ms = ttl_ms;
257 result.cache_scope = cache_scope;
258 Ok(result)
259 }
260
261 async fn list_resources(
262 &self,
263 _request: Option<PaginatedRequestParams>,
264 ctx: RequestContext<RoleServer>,
265 ) -> Result<ListResourcesResult, McpError> {
266 let (ttl_ms, cache_scope) =
267 cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
268 let mut result =
269 ListResourcesResult::with_all_items(vec![Resource::new("file://readme", "README")
270 .with_description("Project README documentation")
271 .with_mime_type("text/markdown")]);
272 result.ttl_ms = ttl_ms;
273 result.cache_scope = cache_scope;
274 Ok(result)
275 }
276
277 async fn list_prompts(
278 &self,
279 _request: Option<PaginatedRequestParams>,
280 ctx: RequestContext<RoleServer>,
281 ) -> Result<ListPromptsResult, McpError> {
282 let (ttl_ms, cache_scope) =
283 cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
284 let mut result = ListPromptsResult::with_all_items(winx_prompts());
285 result.ttl_ms = ttl_ms;
286 result.cache_scope = cache_scope;
287 Ok(result)
288 }
289
290 async fn get_prompt(
291 &self,
292 request: GetPromptRequestParams,
293 context: RequestContext<RoleServer>,
294 ) -> Result<GetPromptResponse, McpError> {
295 if request.name != "KnowledgeTransfer" {
296 return Err(McpError::invalid_request(
297 format!("Unknown prompt: {}", request.name),
298 None,
299 ));
300 }
301
302 let principal = principal_from_context(&context);
303 let session_prefix = principal.as_ref().map(crate::config::HttpPrincipal::session_prefix);
304 let text = crate::utils::redact::redact(
305 &self.knowledge_transfer_prompt_text(session_prefix.as_deref()).await,
306 )
307 .into_owned();
308 Ok(GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)])
309 .with_description("Knowledge transfer handoff prompt")
310 .into())
311 }
312
313 async fn read_resource(
314 &self,
315 request: ReadResourceRequestParams,
316 ctx: RequestContext<RoleServer>,
317 ) -> Result<ReadResourceResponse, McpError> {
318 let content = match request.uri.as_ref() {
319 "file://readme" => match tokio::fs::read_to_string("README.md").await {
320 Ok(content) => vec![ResourceContents::text(
321 crate::utils::redact::redact(&content).into_owned(),
322 request.uri.clone(),
323 )],
324 Err(_) => vec![ResourceContents::text(
325 "README.md not found".to_string(),
326 request.uri.clone(),
327 )],
328 },
329 _ => {
330 return Err(McpError::invalid_request(
331 format!("Unknown resource URI: {}", request.uri),
332 None,
333 ));
334 }
335 };
336 let (ttl_ms, cache_scope) =
337 cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Private);
338 let mut result = ReadResourceResult::new(content);
339 result.ttl_ms = ttl_ms;
340 result.cache_scope = cache_scope;
341 Ok(result.into())
342 }
343
344 async fn call_tool(
345 &self,
346 request: CallToolRequestParams,
347 context: RequestContext<RoleServer>,
348 ) -> Result<CallToolResponse, McpError> {
349 let principal = principal_from_context(&context);
350 let affinity = session_affinity_from_context(&context);
351 let (request, scope) =
352 scope_tool_request(request, principal, affinity).map_err(|error| {
353 McpError::invalid_request(format!("Cannot scope remote request: {error}"), None)
354 })?;
355 let usage = UsageEvent::start(&request, &scope);
356 if context.client_capabilities().is_some_and(|capabilities| capabilities.supports_tasks())
357 && Self::bash_task_is_eligible(&request)
358 {
359 return match self.enqueue_bash_task(request, scope).await {
360 Ok(task) => {
361 usage.emit("task");
362 Ok(CallToolResponse::Task(task))
363 }
364 Err(error) => {
365 usage.emit("error");
366 Err(error)
367 }
368 };
369 }
370
371 match self.execute_tool_call(request).await {
372 Ok(mut result) => {
373 scope.unscope_result(&mut result);
374 usage.emit("ok");
375 Ok(result.into())
376 }
377 Err(mut error) => {
378 scope.unscope_error(&mut error);
379 usage.emit("error");
380 Err(error)
381 }
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn cache_hints_follow_negotiated_protocol_version() {
392 assert_eq!(
393 cache_hints_for_protocol(Some(ProtocolVersion::V_2026_07_28), CacheScope::Private,),
394 (Some(0), Some(CacheScope::Private))
395 );
396 assert_eq!(
397 cache_hints_for_protocol(Some(ProtocolVersion::V_2026_07_28), CacheScope::Public),
398 (Some(0), Some(CacheScope::Public))
399 );
400 assert_eq!(
401 cache_hints_for_protocol(Some(ProtocolVersion::V_2025_11_25), CacheScope::Private,),
402 (None, None)
403 );
404 assert_eq!(cache_hints_for_protocol(None, CacheScope::Private), (None, None));
405 }
406}