1use std::sync::Arc;
2
3use chub_core::annotations::{
4 clear_annotation, list_annotations, write_annotation, AnnotationKind,
5};
6use chub_core::fetch::{fetch_doc, fetch_doc_full, verify_content_hash};
7use chub_core::registry::{
8 get_entry, list_entries, resolve_doc_path, resolve_entry_file, search_entries, MergedRegistry,
9 ResolvedPath, SearchFilters, TaggedEntry,
10};
11use chub_core::team;
12use chub_core::telemetry::{is_feedback_enabled, send_feedback, FeedbackOpts, VALID_LABELS};
13
14use rmcp::handler::server::router::tool::ToolRouter;
15use rmcp::handler::server::wrapper::Parameters;
16use rmcp::{schemars, tool, tool_router};
17
18fn text_result(data: impl serde::Serialize) -> String {
19 serde_json::to_string_pretty(&data).unwrap_or_else(|_| "{}".to_string())
20}
21
22fn simplify_entry(entry: &TaggedEntry) -> serde_json::Value {
23 let mut val = serde_json::json!({
24 "id": entry.id(),
25 "name": entry.name(),
26 "type": entry.entry_type,
27 "description": entry.description(),
28 "tags": entry.tags(),
29 });
30 if let Some(languages) = entry.languages() {
31 val["languages"] =
32 serde_json::json!(languages.iter().map(|l| &l.language).collect::<Vec<_>>());
33 }
34 val
35}
36
37#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
40pub struct SearchParams {
41 #[schemars(default)]
43 pub query: Option<String>,
44 #[schemars(default)]
46 pub tags: Option<String>,
47 #[schemars(default)]
49 pub lang: Option<String>,
50 #[schemars(default)]
52 pub limit: Option<usize>,
53}
54
55#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
56pub struct GetParams {
57 pub id: String,
59 #[schemars(default)]
61 pub lang: Option<String>,
62 #[schemars(default)]
64 pub version: Option<String>,
65 #[schemars(default)]
67 pub full: Option<bool>,
68 #[schemars(default)]
70 pub file: Option<String>,
71 #[schemars(default)]
73 pub match_env: Option<bool>,
74}
75
76#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
77pub struct ListParams {
78 #[schemars(default)]
80 pub tags: Option<String>,
81 #[schemars(default)]
83 pub lang: Option<String>,
84 #[schemars(default)]
86 pub limit: Option<usize>,
87}
88
89#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
90pub struct AnnotateParams {
91 #[schemars(default)]
93 pub id: Option<String>,
94 #[schemars(default)]
96 pub note: Option<String>,
97 #[schemars(default)]
102 pub kind: Option<String>,
103 #[schemars(default)]
105 pub severity: Option<String>,
106 #[schemars(default)]
108 pub clear: Option<bool>,
109 #[schemars(default)]
111 pub list: Option<bool>,
112 #[schemars(default)]
115 pub scope: Option<String>,
116}
117
118#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
119pub struct FeedbackParams {
120 pub id: String,
122 pub rating: String,
124 #[schemars(default)]
126 pub comment: Option<String>,
127 #[serde(rename = "type")]
129 #[schemars(default)]
130 pub entry_type: Option<String>,
131 #[schemars(default)]
133 pub lang: Option<String>,
134 #[schemars(default)]
136 pub version: Option<String>,
137 #[schemars(default)]
139 pub file: Option<String>,
140 #[schemars(default)]
142 pub labels: Option<Vec<String>>,
143}
144
145#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
146#[allow(dead_code)] pub struct ContextParams {
148 #[schemars(default)]
150 pub task: Option<String>,
151 #[schemars(default)]
153 pub files_open: Option<Vec<String>>,
154 #[schemars(default)]
156 pub profile: Option<String>,
157 #[schemars(default)]
159 pub max_tokens: Option<usize>,
160}
161
162#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
163pub struct PinsParams {
164 #[schemars(default)]
166 pub id: Option<String>,
167 #[schemars(default)]
169 pub lang: Option<String>,
170 #[schemars(default)]
172 pub version: Option<String>,
173 #[schemars(default)]
175 pub reason: Option<String>,
176 #[schemars(default)]
178 pub remove: Option<bool>,
179 #[schemars(default)]
181 pub list: Option<bool>,
182}
183
184#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
185pub struct TrackParams {
186 pub action: String,
188 #[schemars(default)]
190 pub session_id: Option<String>,
191 #[schemars(default)]
193 pub days: Option<u64>,
194}
195
196#[derive(Debug, Clone)]
199pub struct ChubMcpServer {
200 pub merged: Arc<MergedRegistry>,
201 pub tool_router: ToolRouter<Self>,
202}
203
204impl ChubMcpServer {
205 pub fn new(merged: Arc<MergedRegistry>) -> Self {
206 Self {
207 merged,
208 tool_router: Self::tool_router(),
209 }
210 }
211}
212
213#[tool_router]
214impl ChubMcpServer {
215 #[tool(
216 name = "chub_search",
217 description = "Search Context Hub for docs and skills by query, tags, or language"
218 )]
219 async fn handle_search(&self, Parameters(params): Parameters<SearchParams>) -> String {
220 let limit = params.limit.unwrap_or(20);
221 let filters = SearchFilters {
222 tags: params.tags,
223 lang: params.lang,
224 entry_type: None,
225 };
226
227 let t0 = std::time::Instant::now();
228 let entries = if let Some(ref query) = params.query {
229 search_entries(query, &filters, &self.merged)
230 } else {
231 list_entries(&filters, &self.merged)
232 };
233 let elapsed = t0.elapsed().as_millis() as u64;
234
235 if let Some(ref query) = params.query {
236 team::analytics::record_search(query, entries.len(), Some(elapsed), Some("mcp-server"));
237 }
238 team::analytics::record_mcp_call("chub_search", Some(elapsed), Some("mcp-server"));
239
240 let sliced: Vec<_> = entries.iter().take(limit).collect();
241 text_result(serde_json::json!({
242 "results": sliced.iter().map(|e| simplify_entry(e)).collect::<Vec<_>>(),
243 "total": entries.len(),
244 "showing": sliced.len(),
245 }))
246 }
247
248 #[tool(
249 name = "chub_get",
250 description = "Fetch the content of a doc or skill by ID from Context Hub"
251 )]
252 async fn handle_get(&self, Parameters(params): Parameters<GetParams>) -> String {
253 if let Some(ref file) = params.file {
255 if file.contains("..") || file.contains('\\') {
256 return text_result(serde_json::json!({
257 "error": format!("Invalid file path: \"{}\". Path traversal is not allowed.", file),
258 }));
259 }
260 let normalized = std::path::Path::new("/")
261 .join(file)
262 .to_string_lossy()
263 .to_string();
264 let normalized = normalized.trim_start_matches('/').to_string();
265 if normalized != *file {
266 return text_result(serde_json::json!({
267 "error": format!("Invalid file path: \"{}\". Path traversal is not allowed.", file),
268 }));
269 }
270 }
271
272 let result = get_entry(¶ms.id, &self.merged);
273
274 if result.ambiguous {
275 return text_result(serde_json::json!({
276 "error": format!("Ambiguous entry ID \"{}\". Be specific:", params.id),
277 "alternatives": result.alternatives,
278 }));
279 }
280
281 let entry = match result.entry {
282 Some(e) => e,
283 None => {
284 return text_result(serde_json::json!({
285 "error": format!("Entry \"{}\" not found.", params.id),
286 "suggestion": "Use chub_search to find available entries.",
287 }));
288 }
289 };
290
291 let entry_type = entry.entry_type;
292
293 let mut effective_lang = params.lang.clone();
295 let mut effective_version = params.version.clone();
296 let mut pin_notice = String::new();
297 if let Some(pin) = team::pins::get_pin(entry.id()) {
298 if effective_lang.is_none() {
299 effective_lang = pin.lang.clone();
300 }
301 if effective_version.is_none() {
302 effective_version = pin.version.clone();
303 }
304 pin_notice = team::team_annotations::get_pin_notice(
305 pin.version.as_deref(),
306 pin.lang.as_deref(),
307 pin.reason.as_deref(),
308 );
309 }
310
311 if params.match_env.unwrap_or(false) && effective_version.is_none() {
313 let cwd = std::env::current_dir().unwrap_or_default();
314 let deps = chub_core::team::detect::detect_dependencies(&cwd);
315 if let Some(dep) = find_matching_dep_for_entry(entry.id(), &deps) {
316 if let Some(ref ver) = dep.version {
317 let clean = ver.trim_start_matches(|c: char| {
318 c == '^' || c == '~' || c == '=' || c == '>' || c == '<' || c == 'v'
319 });
320 if !clean.is_empty() {
321 effective_version = Some(clean.to_string());
322 }
323 }
324 }
325 }
326
327 let resolved = resolve_doc_path(
328 &entry,
329 effective_lang.as_deref(),
330 effective_version.as_deref(),
331 );
332
333 let resolved = match resolved {
334 Some(r) => r,
335 None => {
336 return text_result(serde_json::json!({
337 "error": format!("Could not resolve path for \"{}\".", params.id),
338 }));
339 }
340 };
341
342 match &resolved {
343 ResolvedPath::VersionNotFound {
344 requested,
345 available,
346 } => {
347 return text_result(serde_json::json!({
348 "error": format!("Version \"{}\" not found for \"{}\".", requested, params.id),
349 "available": available,
350 }));
351 }
352 ResolvedPath::NeedsLanguage { available } => {
353 return text_result(serde_json::json!({
354 "error": format!("Multiple languages available for \"{}\". Specify the lang parameter.", params.id),
355 "available": available,
356 }));
357 }
358 ResolvedPath::Ok { .. } => {}
359 }
360
361 let (file_path, base_path, files) = match resolve_entry_file(&resolved, entry_type) {
362 Some(r) => r,
363 None => {
364 return text_result(serde_json::json!({
365 "error": format!("\"{}\": unresolved", params.id),
366 }));
367 }
368 };
369
370 let (source, content_hash) = match &resolved {
371 ResolvedPath::Ok {
372 source,
373 content_hash,
374 ..
375 } => (source.clone(), content_hash.clone()),
376 _ => unreachable!(),
377 };
378
379 let mut content = if let Some(ref file) = params.file {
380 if !files.contains(&file.to_string()) {
381 let entry_file_name = if entry_type == "skill" {
382 "SKILL.md"
383 } else {
384 "DOC.md"
385 };
386 let available: Vec<_> = files
387 .iter()
388 .filter(|f| f.as_str() != entry_file_name)
389 .collect();
390 return text_result(serde_json::json!({
391 "error": format!("File \"{}\" not found in {}.", file, params.id),
392 "available": if available.is_empty() { vec!["(none)".to_string()] } else { available.iter().map(|s| s.to_string()).collect() },
393 }));
394 }
395 let path = format!("{}/{}", base_path, file);
396 match fetch_doc(&source, &path).await {
397 Ok(c) => c,
398 Err(e) => {
399 return text_result(serde_json::json!({
400 "error": format!("Failed to fetch \"{}\": {}", params.id, e),
401 }));
402 }
403 }
404 } else if params.full.unwrap_or(false) && !files.is_empty() {
405 match fetch_doc_full(&source, &base_path, &files).await {
406 Ok(all_files) => all_files
407 .iter()
408 .map(|(name, content)| format!("# FILE: {}\n\n{}", name, content))
409 .collect::<Vec<_>>()
410 .join("\n\n---\n\n"),
411 Err(e) => {
412 return text_result(serde_json::json!({
413 "error": format!("Failed to fetch \"{}\": {}", params.id, e),
414 }));
415 }
416 }
417 } else {
418 match fetch_doc(&source, &file_path).await {
419 Ok(c) => {
420 if let Err(e) = verify_content_hash(&c, content_hash.as_deref(), &file_path) {
422 return text_result(serde_json::json!({
423 "error": format!("{}", e),
424 "warning": "Content may have been tampered with.",
425 }));
426 }
427 c
428 }
429 Err(e) => {
430 return text_result(serde_json::json!({
431 "error": format!("Failed to fetch \"{}\": {}", params.id, e),
432 }));
433 }
434 }
435 };
436
437 if !pin_notice.is_empty() {
439 content.push_str(&pin_notice);
440 }
441
442 if let Some(merged_ann) =
444 team::team_annotations::get_merged_annotation_async(entry.id()).await
445 {
446 content.push_str(&format!(
447 "\n\n---\nā USER-CONTRIBUTED ANNOTATIONS (not part of official documentation):\n{}\n",
448 merged_ann
449 ));
450 }
451
452 team::analytics::record_fetch(entry.id(), Some("mcp-server"));
454 team::analytics::record_mcp_call("chub_get", None, Some("mcp-server"));
455
456 content
457 }
458
459 #[tool(
460 name = "chub_list",
461 description = "List all available docs and skills in Context Hub"
462 )]
463 async fn handle_list(&self, Parameters(params): Parameters<ListParams>) -> String {
464 let limit = params.limit.unwrap_or(50);
465 let filters = SearchFilters {
466 tags: params.tags,
467 lang: params.lang,
468 entry_type: None,
469 };
470
471 let entries = list_entries(&filters, &self.merged);
472 let sliced: Vec<_> = entries.iter().take(limit).collect();
473
474 team::analytics::record_mcp_call("chub_list", None, Some("mcp-server"));
475
476 text_result(serde_json::json!({
477 "entries": sliced.iter().map(|e| simplify_entry(e)).collect::<Vec<_>>(),
478 "total": entries.len(),
479 "showing": sliced.len(),
480 }))
481 }
482
483 #[tool(
484 name = "chub_annotate",
485 description = "Read, write, clear, or list agent annotations for a doc entry. \
486 USE THIS TOOL proactively after resolving something non-obvious with a library: \
487 (1) Discovered an undocumented bug or broken param? Write kind=issue. \
488 (2) Found a workaround? Write kind=fix. \
489 (3) Validated a useful pattern? Write kind=practice. \
490 Always read existing annotations first (id alone) to avoid duplicates. \
491 Be concise and reproducible ā include the exact call, param, or value. \
492 Scope: auto (default), personal, team, or org (requires annotation_server config). \
493 Modes: (a) list=true to list all, (b) id+note+kind to write, (c) id+clear=true to delete, (d) id alone to read."
494 )]
495 async fn handle_annotate(&self, Parameters(params): Parameters<AnnotateParams>) -> String {
496 if params.list.unwrap_or(false) {
497 let scope = params.scope.as_deref().unwrap_or("auto");
498 if scope == "org" {
499 let annotations = chub_core::team::org_annotations::list_org_annotations().await;
500 let total = annotations.len();
501 return text_result(serde_json::json!({
502 "annotations": annotations,
503 "scope": "org",
504 "total": total,
505 }));
506 } else if chub_core::team::project::project_chub_dir().is_some() {
507 let annotations = chub_core::team::team_annotations::list_team_annotations();
508 return text_result(serde_json::json!({
509 "annotations": annotations,
510 "scope": "team",
511 "total": annotations.len(),
512 }));
513 }
514 let annotations = list_annotations();
515 return text_result(serde_json::json!({
516 "annotations": annotations,
517 "scope": "personal",
518 "total": annotations.len(),
519 }));
520 }
521
522 let id = match params.id {
523 Some(id) => id,
524 None => {
525 return text_result(serde_json::json!({
526 "error": "Missing required parameter: id. Provide an entry ID or use list mode.",
527 }));
528 }
529 };
530
531 if id.len() > 200 {
533 return text_result(serde_json::json!({
534 "error": "Entry ID too long (max 200 characters).",
535 }));
536 }
537 if !id
538 .chars()
539 .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '/')
540 {
541 return text_result(serde_json::json!({
542 "error": "Entry ID contains invalid characters. Use only alphanumeric, hyphens, underscores, dots, and slashes.",
543 }));
544 }
545
546 if params.clear.unwrap_or(false) {
547 let scope = params.scope.as_deref().unwrap_or("auto");
548 let (scope_label, removed) = if scope == "org" {
549 match chub_core::team::org_annotations::clear_org_annotation(&id).await {
550 Ok(r) => ("org", r),
551 Err(e) => {
552 return text_result(serde_json::json!({"error": e, "id": id}));
553 }
554 }
555 } else if scope == "personal" {
556 ("personal", clear_annotation(&id))
557 } else {
558 if chub_core::team::project::project_chub_dir().is_some() {
560 (
561 "team",
562 chub_core::team::team_annotations::clear_team_annotation(&id),
563 )
564 } else {
565 ("personal", clear_annotation(&id))
566 }
567 };
568 return text_result(serde_json::json!({
569 "status": if removed { "cleared" } else { "not_found" },
570 "scope": scope_label,
571 "id": id,
572 }));
573 }
574
575 if let Some(note) = params.note {
576 let kind = params
577 .kind
578 .as_deref()
579 .and_then(AnnotationKind::parse)
580 .unwrap_or(AnnotationKind::Note);
581
582 let scope = params.scope.as_deref().unwrap_or("auto");
583
584 if scope == "org" {
585 let author = get_agent_author();
586 return match chub_core::team::org_annotations::write_org_annotation(
587 &id,
588 ¬e,
589 &author,
590 kind.clone(),
591 params.severity.clone(),
592 )
593 .await
594 {
595 Ok(saved) => text_result(serde_json::json!({
596 "status": "saved",
597 "scope": "org",
598 "kind": kind.as_str(),
599 "annotation": saved,
600 })),
601 Err(e) => text_result(serde_json::json!({"error": e, "id": id})),
602 };
603 }
604
605 if scope == "personal" {
606 let saved = write_annotation(&id, ¬e, kind.clone(), params.severity.clone());
607 team::analytics::record_annotate(&id, kind.as_str());
608 team::analytics::record_mcp_call("chub_annotate", None, Some("mcp-server"));
609 return text_result(serde_json::json!({
610 "status": "saved",
611 "scope": "personal",
612 "kind": kind.as_str(),
613 "annotation": saved,
614 }));
615 }
616
617 if chub_core::team::project::project_chub_dir().is_some() {
619 let author = get_agent_author();
620 let result = match chub_core::team::team_annotations::write_team_annotation(
621 &id,
622 ¬e,
623 &author,
624 kind.clone(),
625 params.severity.clone(),
626 ) {
627 Some(saved) => {
628 team::analytics::record_annotate(&id, kind.as_str());
629 team::analytics::record_mcp_call("chub_annotate", None, Some("mcp-server"));
630 let auto_push =
632 chub_core::team::org_annotations::get_annotation_server_config()
633 .map(|c| c.auto_push)
634 .unwrap_or(false);
635 if auto_push {
636 let _ = chub_core::team::org_annotations::write_org_annotation(
637 &id,
638 ¬e,
639 &author,
640 kind.clone(),
641 params.severity.clone(),
642 )
643 .await;
644 }
645 text_result(serde_json::json!({
646 "status": "saved",
647 "scope": "team",
648 "kind": kind.as_str(),
649 "annotation": saved,
650 }))
651 }
652 None => text_result(serde_json::json!({
653 "error": "Failed to write team annotation. Check that .chub/annotations/ is writable.",
654 "id": id,
655 })),
656 };
657 return result;
658 }
659
660 let saved = write_annotation(&id, ¬e, kind.clone(), params.severity.clone());
662 return text_result(serde_json::json!({
663 "status": "saved",
664 "scope": "personal",
665 "kind": kind.as_str(),
666 "annotation": saved,
667 }));
668 }
669
670 if let Some(merged) = team::team_annotations::get_merged_annotation_async(&id).await {
672 return text_result(serde_json::json!({ "annotation": merged }));
673 }
674 text_result(serde_json::json!({ "status": "no_annotation", "id": id }))
675 }
676
677 #[tool(
678 name = "chub_context",
679 description = "Get optimal context for a task: returns relevant pinned docs, team annotations, project context, and profile rules in one call"
680 )]
681 async fn handle_context(&self, Parameters(params): Parameters<ContextParams>) -> String {
682 let mut result = serde_json::json!({});
683
684 let pins = team::pins::list_pins();
686 if !pins.is_empty() {
687 result["pins"] = serde_json::json!(pins);
688 }
689
690 if let Some(ref profile_name) = params.profile {
692 if let Ok(resolved) = team::profiles::resolve_profile(profile_name) {
693 result["profile"] = serde_json::json!({
694 "name": resolved.name,
695 "rules": resolved.rules,
696 "context": resolved.context,
697 });
698 }
699 } else if let Some(profile_name) = team::profiles::get_active_profile() {
700 if let Ok(resolved) = team::profiles::resolve_profile(&profile_name) {
701 result["profile"] = serde_json::json!({
702 "name": resolved.name,
703 "rules": resolved.rules,
704 "context": resolved.context,
705 });
706 }
707 }
708
709 let context_docs = team::context::list_context_docs();
711 if !context_docs.is_empty() {
712 result["project_context"] = serde_json::json!(context_docs);
713 }
714
715 let mut annotations = Vec::new();
717 for pin in &pins {
718 if let Some(merged_ann) = team::team_annotations::get_merged_annotation(&pin.id) {
719 annotations.push(serde_json::json!({
720 "id": pin.id,
721 "annotation": merged_ann,
722 }));
723 }
724 }
725 if !annotations.is_empty() {
726 result["annotations"] = serde_json::json!(annotations);
727 }
728
729 if let Some(ref task) = params.task {
731 result["task"] = serde_json::json!(task);
732 }
733
734 text_result(result)
735 }
736
737 #[tool(
738 name = "chub_pins",
739 description = "List, add, or remove pinned docs. Pinned docs have locked versions for the team."
740 )]
741 async fn handle_pins(&self, Parameters(params): Parameters<PinsParams>) -> String {
742 if params.list.unwrap_or(false) || (params.id.is_none() && params.remove.is_none()) {
743 let pins = team::pins::list_pins();
744 return text_result(serde_json::json!({
745 "pins": pins,
746 "total": pins.len(),
747 }));
748 }
749
750 let id = match params.id {
751 Some(id) => id,
752 None => {
753 return text_result(serde_json::json!({
754 "error": "Missing required parameter: id",
755 }));
756 }
757 };
758
759 if params.remove.unwrap_or(false) {
760 match team::pins::remove_pin(&id) {
761 Ok(true) => text_result(serde_json::json!({"status": "unpinned", "id": id})),
762 Ok(false) => text_result(serde_json::json!({"status": "not_found", "id": id})),
763 Err(e) => text_result(serde_json::json!({"error": e.to_string()})),
764 }
765 } else {
766 match team::pins::add_pin(&id, params.lang, params.version, params.reason, None) {
767 Ok(()) => text_result(serde_json::json!({"status": "pinned", "id": id})),
768 Err(e) => text_result(serde_json::json!({"error": e.to_string()})),
769 }
770 }
771 }
772
773 #[tool(
774 name = "chub_feedback",
775 description = "Send quality feedback (thumbs up/down) for a doc or skill to help authors improve content"
776 )]
777 async fn handle_feedback(&self, Parameters(params): Parameters<FeedbackParams>) -> String {
778 if !is_feedback_enabled() {
779 return text_result(serde_json::json!({
780 "status": "skipped",
781 "reason": "feedback_disabled",
782 }));
783 }
784
785 let mut entry_type = params.entry_type.clone();
787 if entry_type.is_none() {
788 let result = get_entry(¶ms.id, &self.merged);
789 if let Some(ref entry) = result.entry {
790 entry_type = Some(entry.entry_type.to_string());
791 }
792 }
793 let entry_type = entry_type.unwrap_or_else(|| "doc".to_string());
794
795 let labels = params.labels.map(|ls| {
797 ls.into_iter()
798 .filter(|l| VALID_LABELS.contains(&l.as_str()))
799 .collect::<Vec<_>>()
800 });
801
802 let result = send_feedback(
803 ¶ms.id,
804 &entry_type,
805 ¶ms.rating,
806 FeedbackOpts {
807 comment: params.comment,
808 doc_lang: params.lang,
809 doc_version: params.version,
810 target_file: params.file,
811 labels,
812 agent: Some("mcp-server".to_string()),
813 model: None,
814 cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
815 source: None,
816 },
817 )
818 .await;
819
820 text_result(result)
821 }
822
823 #[tool(
824 name = "chub_track",
825 description = "Query AI usage tracking data ā session status, cost reports, session history, and session details"
826 )]
827 async fn handle_track(&self, Parameters(params): Parameters<TrackParams>) -> String {
828 team::analytics::record_mcp_call("chub_track", None, None);
829
830 let days = params.days.unwrap_or(30);
831
832 match params.action.as_str() {
833 "status" => {
834 let active = team::sessions::get_active_session();
835 let journals = team::session_journal::list_journal_files();
836 let entire_states = team::tracking::session_state::list_states();
837 let active_state = active
838 .as_ref()
839 .and_then(|s| team::tracking::session_state::load_state(&s.session_id));
840 text_result(serde_json::json!({
841 "active_session": active.as_ref().map(|s| serde_json::json!({
842 "session_id": s.session_id,
843 "agent": s.agent,
844 "model": s.model,
845 "started_at": s.started_at,
846 "turns": s.turns,
847 "tool_calls": s.tool_calls,
848 "tokens": {
849 "input": s.tokens.input,
850 "output": s.tokens.output,
851 "total": s.tokens.total(),
852 },
853 "phase": active_state.as_ref().map(|st| format!("{:?}", st.phase)),
854 "files_touched": active_state.as_ref().map(|st| st.files_touched.len()),
855 "transcript_linked": active_state.as_ref().map(|st| st.transcript_path.is_some()),
856 })),
857 "local_journals": journals.len(),
858 "entire_sessions": entire_states.len(),
859 }))
860 }
861 "report" => {
862 let report = team::sessions::generate_report(days);
863 text_result(report)
864 }
865 "log" => {
866 let sessions = team::sessions::list_sessions(days);
867 let summaries: Vec<_> = sessions
868 .iter()
869 .map(|s| {
870 serde_json::json!({
871 "session_id": s.session_id,
872 "agent": s.agent,
873 "model": s.model,
874 "started_at": s.started_at,
875 "duration_s": s.duration_s,
876 "turns": s.turns,
877 "tokens_total": s.tokens.total(),
878 "tool_calls": s.tool_calls,
879 "est_cost_usd": s.est_cost_usd,
880 })
881 })
882 .collect();
883 text_result(summaries)
884 }
885 "show" => {
886 let session_id = match params.session_id {
887 Some(id) => id,
888 None => {
889 return text_result(serde_json::json!({
890 "error": "session_id is required for 'show' action"
891 }))
892 }
893 };
894 if let Some(session) = team::sessions::get_session(&session_id) {
895 text_result(session)
896 } else {
897 text_result(serde_json::json!({
898 "error": format!("Session '{}' not found", session_id)
899 }))
900 }
901 }
902 other => text_result(serde_json::json!({
903 "error": format!("Unknown action: '{}'. Use: status, report, log, show", other)
904 })),
905 }
906 }
907}
908
909fn get_agent_author() -> String {
910 std::env::var("USER")
911 .or_else(|_| std::env::var("USERNAME"))
912 .unwrap_or_else(|_| "agent".to_string())
913}
914
915fn find_matching_dep_for_entry<'a>(
917 entry_id: &str,
918 deps: &'a [chub_core::team::detect::DetectedDep],
919) -> Option<&'a chub_core::team::detect::DetectedDep> {
920 let id_parts: Vec<&str> = entry_id.split('/').collect();
921 let search_name = if !id_parts.is_empty() {
922 id_parts[0].to_lowercase()
923 } else {
924 entry_id.to_lowercase()
925 };
926 deps.iter().find(|d| d.name.to_lowercase() == search_name)
927}