Skip to main content

chub_cli/mcp/
tools.rs

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// --- Tool parameter structs ---
38
39#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
40pub struct SearchParams {
41    /// Search query. Omit to list all entries.
42    #[schemars(default)]
43    pub query: Option<String>,
44    /// Comma-separated tag filter (e.g. "openai,chat")
45    #[schemars(default)]
46    pub tags: Option<String>,
47    /// Filter by language (e.g. "python", "js")
48    #[schemars(default)]
49    pub lang: Option<String>,
50    /// Max results (default 20)
51    #[schemars(default)]
52    pub limit: Option<usize>,
53}
54
55#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
56pub struct GetParams {
57    /// Entry ID (e.g. "openai/chat", "stripe/api"). Use source:id for disambiguation.
58    pub id: String,
59    /// Language variant (e.g. "python", "js"). Auto-selected if only one.
60    #[schemars(default)]
61    pub lang: Option<String>,
62    /// Specific version (e.g. "1.52.0"). Defaults to recommended.
63    #[schemars(default)]
64    pub version: Option<String>,
65    /// Fetch all files, not just the entry point (default false)
66    #[schemars(default)]
67    pub full: Option<bool>,
68    /// Fetch a specific file by path (e.g. "references/streaming.md")
69    #[schemars(default)]
70    pub file: Option<String>,
71    /// Auto-detect version from project dependencies (default false)
72    #[schemars(default)]
73    pub match_env: Option<bool>,
74}
75
76#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
77pub struct ListParams {
78    /// Comma-separated tag filter
79    #[schemars(default)]
80    pub tags: Option<String>,
81    /// Filter by language
82    #[schemars(default)]
83    pub lang: Option<String>,
84    /// Max entries (default 50)
85    #[schemars(default)]
86    pub limit: Option<usize>,
87}
88
89#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
90pub struct AnnotateParams {
91    /// Entry ID to annotate (e.g. "openai/chat"). Required unless using list mode.
92    #[schemars(default)]
93    pub id: Option<String>,
94    /// Annotation text to save. Omit to read existing annotation.
95    #[schemars(default)]
96    pub note: Option<String>,
97    /// Annotation kind: "note" (default), "issue" (bug/gotcha), "fix" (workaround), "practice" (team convention).
98    /// Use "issue" when you discover an undocumented bug or broken param.
99    /// Use "fix" when you find a workaround for an issue.
100    /// Use "practice" when you validate a pattern the team should follow.
101    #[schemars(default)]
102    pub kind: Option<String>,
103    /// Severity for issue annotations: "high", "medium", or "low". Only used when kind="issue".
104    #[schemars(default)]
105    pub severity: Option<String>,
106    /// Remove the annotation for this entry (default false)
107    #[schemars(default)]
108    pub clear: Option<bool>,
109    /// List all annotations (default false). When true, id is not needed.
110    #[schemars(default)]
111    pub list: Option<bool>,
112    /// Scope: "auto" (default), "personal", "team", or "org".
113    /// auto = team when .chub/ exists, personal otherwise (org requires explicit scope).
114    #[schemars(default)]
115    pub scope: Option<String>,
116}
117
118#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
119pub struct FeedbackParams {
120    /// Entry ID to rate (e.g. "openai/chat")
121    pub id: String,
122    /// Thumbs up or down
123    pub rating: String,
124    /// Optional comment explaining the rating
125    #[schemars(default)]
126    pub comment: Option<String>,
127    /// Entry type. Auto-detected if omitted.
128    #[serde(rename = "type")]
129    #[schemars(default)]
130    pub entry_type: Option<String>,
131    /// Language variant rated
132    #[schemars(default)]
133    pub lang: Option<String>,
134    /// Version rated
135    #[schemars(default)]
136    pub version: Option<String>,
137    /// Specific file rated
138    #[schemars(default)]
139    pub file: Option<String>,
140    /// Structured feedback labels
141    #[schemars(default)]
142    pub labels: Option<Vec<String>>,
143}
144
145#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
146#[allow(dead_code)] // Fields exposed via MCP JSON schema, read by serde
147pub struct ContextParams {
148    /// Task description to find relevant context for
149    #[schemars(default)]
150    pub task: Option<String>,
151    /// Files currently open (for auto-profile detection)
152    #[schemars(default)]
153    pub files_open: Option<Vec<String>>,
154    /// Profile name to scope context to
155    #[schemars(default)]
156    pub profile: Option<String>,
157    /// Maximum token budget (soft limit)
158    #[schemars(default)]
159    pub max_tokens: Option<usize>,
160}
161
162#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
163pub struct PinsParams {
164    /// Entry ID to pin or unpin
165    #[schemars(default)]
166    pub id: Option<String>,
167    /// Language variant
168    #[schemars(default)]
169    pub lang: Option<String>,
170    /// Version to lock
171    #[schemars(default)]
172    pub version: Option<String>,
173    /// Reason for pinning
174    #[schemars(default)]
175    pub reason: Option<String>,
176    /// Remove the pin (default false)
177    #[schemars(default)]
178    pub remove: Option<bool>,
179    /// List all pins (default false)
180    #[schemars(default)]
181    pub list: Option<bool>,
182}
183
184#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
185pub struct TrackParams {
186    /// Action: "status", "report", "log", "show"
187    pub action: String,
188    /// Session ID (for "show" action)
189    #[schemars(default)]
190    pub session_id: Option<String>,
191    /// Number of days for report/log (default 30)
192    #[schemars(default)]
193    pub days: Option<u64>,
194}
195
196// --- MCP Server ---
197
198#[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        // Validate file parameter (path traversal) — reject suspicious paths
254        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(&params.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        // Apply pin overrides: if the entry is pinned, use pinned lang/version as defaults
294        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        // Auto-detect version from project dependencies if match_env is set
312        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                    // Verify content integrity if hash is available
421                    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        // Append pin notice if this doc is pinned
438        if !pin_notice.is_empty() {
439            content.push_str(&pin_notice);
440        }
441
442        // Append merged annotations (org + team + personal) with trust framing
443        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        // Record analytics
453        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        // Validate entry ID
532        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                // "auto" or "team"
559                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                    &note,
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, &note, 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            // "auto" or "team": when a project .chub/ dir is present, write to team tier.
618            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                    &note,
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                        // auto_push if configured
631                        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                                &note,
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            // No project dir: write personal annotation (overwrite semantics).
661            let saved = write_annotation(&id, &note, 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        // Read mode: return merged all-tier annotation if available.
671        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        // Pinned docs
685        let pins = team::pins::list_pins();
686        if !pins.is_empty() {
687            result["pins"] = serde_json::json!(pins);
688        }
689
690        // Active profile rules
691        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        // Project context docs
710        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        // Team annotations for pinned docs
716        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        // Task relevance scoring (if task provided)
730        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        // Auto-detect entry type
786        let mut entry_type = params.entry_type.clone();
787        if entry_type.is_none() {
788            let result = get_entry(&params.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        // Validate labels
796        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            &params.id,
804            &entry_type,
805            &params.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
915/// Find a matching dependency for a registry entry ID.
916fn 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}