Skip to main content

kaizen/mcp/
handler.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! MCP `#[tool]` handlers (stdio server).
3
4use crate::core::data_source::DataSource;
5use crate::shell::exp::NewArgs;
6use crate::shell::ingest::{IngestSource, ingest_hook_string};
7use crate::shell::{cli, exp, init, insights, metrics, retro, sync};
8use rmcp::ServerHandler;
9use rmcp::handler::server::wrapper::Parameters;
10use rmcp::model::{CallToolResult, Content, ErrorData};
11use rmcp::schemars;
12use rmcp::tool;
13use rmcp::tool_handler;
14use rmcp::tool_router;
15use serde::Deserialize;
16
17/// Static help for model routing (keep in sync with `kaizen --help` groups).
18const MCP_CAPABILITIES: &str = r#"Kaizen MCP exposes most `kaizen` CLI workflows as tools. Shell-only today: doctor, guidance, gc, completions, proxy run, telemetry subcommands (init, doctor, pull, print-schema, configure, print-effective-config).
19
20- kaizen_summary — Session counts, USD cost, by-agent/model, top tools. Use for spend and volume. Optional json=true.
21- kaizen_metrics — Code hotspots, slow tools (p95), token-heavy tools, churn. Use for **repository** and tool latency. Optional json.
22- kaizen_sessions_list / kaizen_session_show — Session list and one session metadata. Optional json on list; optional `limit` caps rows (newest first). `kaizen_exp_report` supports `refresh: true` for a full transcript rescan before computing the report (matches CLI `kaizen exp report --refresh`).
23- mcp/search_sessions — BM25 event search over current workspace. Supports since, agent, kind, limit.
24- kaizen_insights — Activity dashboard (7d). kaizen_retro — weekly bets. kaizen_exp_* — experiments.
25- List/summary/insights/metrics/retro are cache-first; set refresh=true to force a full transcript rescan (matches CLI --refresh).
26- sessions_list/summary/insights/metrics also accept all_workspaces=true to aggregate across registered workspace-local DBs.
27- kaizen_ingest_hook — same as `kaizen ingest hook` (rare; hooks call this).
28- kaizen_init — idempotent .kaizen/ + hook patches. kaizen_sync_* — outbox. kaizen_tui — not available (returns JSON stub).
29
30Docs: https://github.com/marquesds/kaizen/blob/main/docs/mcp.md
31"#;
32
33fn ok_str(s: String) -> Result<CallToolResult, ErrorData> {
34    Ok(CallToolResult::success(vec![Content::text(s)]))
35}
36
37fn err_str(msg: String) -> Result<CallToolResult, ErrorData> {
38    Ok(CallToolResult::error(vec![Content::text(msg)]))
39}
40
41async fn run_blocking<T, F>(f: F) -> Result<T, ErrorData>
42where
43    F: FnOnce() -> Result<T, anyhow::Error> + Send + 'static,
44    T: Send + 'static,
45{
46    tokio::task::spawn_blocking(f)
47        .await
48        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
49        .map_err(|e: anyhow::Error| ErrorData::internal_error(format!("{e:#}"), None))
50}
51
52fn opt_path(ws: &Option<String>) -> Option<std::path::PathBuf> {
53    ws.as_ref().map(std::path::PathBuf::from)
54}
55
56fn resolve_ws(ws: &WorkspaceArg) -> Result<Option<std::path::PathBuf>, ErrorData> {
57    match (ws.workspace.as_deref(), ws.project.as_deref()) {
58        (None, None) => Ok(None),
59        (w, p) => cli::resolve_target(w.map(std::path::Path::new), p)
60            .map(|(path, _)| Some(path))
61            .map_err(|e| ErrorData::internal_error(e.to_string(), None)),
62    }
63}
64
65/// Shared workspace argument for tools.
66#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
67struct WorkspaceArg {
68    /// Workspace root (repository path). If omitted, uses the process current directory.
69    workspace: Option<String>,
70    /// Project name shorthand (mutually exclusive with workspace).
71    project: Option<String>,
72}
73
74/// Workspace + optional machine-readable JSON (matches CLI `--json` on list/summary).
75#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
76struct WorkspaceJsonArg {
77    /// Workspace root (repository path). If omitted, uses the process current directory.
78    workspace: Option<String>,
79    /// When true, read from every registered workspace on this machine.
80    #[serde(default)]
81    all_workspaces: bool,
82    /// When true, return the same pretty JSON as `kaizen sessions list --json` or `kaizen summary --json`.
83    #[serde(default)]
84    json: bool,
85    /// When true, run a full agent transcript rescan (matches `kaizen ... --refresh`).
86    #[serde(default)]
87    refresh: bool,
88    /// Cap sessions returned (newest first); only `kaizen_sessions_list` uses this.
89    #[serde(default)]
90    limit: Option<u32>,
91}
92
93#[derive(Debug, Deserialize, schemars::JsonSchema)]
94struct IngestHookArg {
95    #[serde(flatten)]
96    ws: WorkspaceArg,
97    /// `cursor` or `claude`
98    source: String,
99    /// Same JSON a hook would send on stdin
100    payload: String,
101}
102
103#[derive(Debug, Deserialize, schemars::JsonSchema)]
104struct SessionIdArg {
105    #[serde(flatten)]
106    ws: WorkspaceArg,
107    id: String,
108}
109
110#[derive(Debug, Deserialize, schemars::JsonSchema)]
111struct GetSpanTreeArg {
112    #[serde(flatten)]
113    ws: WorkspaceArg,
114    id: String,
115    #[serde(default)]
116    json: bool,
117}
118
119#[derive(Debug, Deserialize, schemars::JsonSchema)]
120struct SearchSessionsArg {
121    #[serde(flatten)]
122    ws: WorkspaceArg,
123    query: String,
124    #[serde(default)]
125    since: Option<String>,
126    #[serde(default)]
127    agent: Option<String>,
128    #[serde(default)]
129    kind: Option<String>,
130    #[serde(default = "default_search_limit")]
131    limit: usize,
132}
133
134fn default_search_limit() -> usize {
135    50
136}
137
138#[derive(Debug, Deserialize, schemars::JsonSchema)]
139struct MetricsArg {
140    #[serde(flatten)]
141    ws: WorkspaceArg,
142    #[serde(default)]
143    all_workspaces: bool,
144    #[serde(default = "default_days")]
145    days: u32,
146    /// When true, return pretty JSON
147    json: bool,
148    #[serde(default)]
149    force: bool,
150    /// When true, run a full agent transcript rescan (matches `kaizen metrics --refresh`).
151    #[serde(default)]
152    refresh: bool,
153}
154
155fn default_days() -> u32 {
156    7
157}
158
159#[derive(Debug, Deserialize, schemars::JsonSchema)]
160struct MetricsIndexArg {
161    #[serde(flatten)]
162    ws: WorkspaceArg,
163    #[serde(default)]
164    force: bool,
165}
166
167#[derive(Debug, Deserialize, schemars::JsonSchema)]
168struct SyncRunArg {
169    #[serde(flatten)]
170    ws: WorkspaceArg,
171    /// For MCP, must be `true` (single flush). Continuous daemon is not supported here.
172    #[serde(default = "default_once_true")]
173    once: bool,
174}
175
176fn default_once_true() -> bool {
177    true
178}
179
180#[derive(Debug, Deserialize, schemars::JsonSchema)]
181struct RetroArg {
182    #[serde(flatten)]
183    ws: WorkspaceArg,
184    #[serde(default = "default_days")]
185    days: u32,
186    #[serde(default)]
187    dry_run: bool,
188    #[serde(default)]
189    json: bool,
190    #[serde(default)]
191    force: bool,
192    /// When true, run a full agent transcript rescan (matches `kaizen retro --refresh`).
193    #[serde(default)]
194    refresh: bool,
195}
196
197#[derive(Debug, Deserialize, schemars::JsonSchema)]
198struct InsightsArg {
199    #[serde(flatten)]
200    ws: WorkspaceArg,
201    #[serde(default)]
202    all_workspaces: bool,
203    /// When true, run a full agent transcript rescan (matches `kaizen insights --refresh`).
204    #[serde(default)]
205    refresh: bool,
206}
207
208#[derive(Debug, Deserialize, schemars::JsonSchema)]
209struct ExpNewArg {
210    #[serde(flatten)]
211    ws: WorkspaceArg,
212    name: String,
213    hypothesis: String,
214    change: String,
215    metric: String,
216    #[serde(default = "default_bind")]
217    bind: String,
218    #[serde(default = "default_duration")]
219    duration_days: u32,
220    #[serde(default = "default_target")]
221    target_pct: f64,
222    control_commit: Option<String>,
223    treatment_commit: Option<String>,
224    control_branch: Option<String>,
225    treatment_branch: Option<String>,
226}
227
228fn default_bind() -> String {
229    "git".to_string()
230}
231fn default_duration() -> u32 {
232    14
233}
234fn default_target() -> f64 {
235    -10.0
236}
237
238#[derive(Debug, Deserialize, schemars::JsonSchema)]
239struct ExpIdArg {
240    #[serde(flatten)]
241    ws: WorkspaceArg,
242    id: String,
243}
244
245#[derive(Debug, Deserialize, schemars::JsonSchema)]
246struct ExpTagArg {
247    #[serde(flatten)]
248    ws: WorkspaceArg,
249    id: String,
250    session: String,
251    /// control | treatment | excluded
252    variant: String,
253}
254
255#[derive(Debug, Deserialize, schemars::JsonSchema)]
256struct ExpReportArg {
257    #[serde(flatten)]
258    ws: WorkspaceArg,
259    id: String,
260    #[serde(default)]
261    json: bool,
262    /// Full transcript rescan before computing the report.
263    #[serde(default)]
264    refresh: bool,
265}
266
267#[derive(Debug, Deserialize, schemars::JsonSchema)]
268struct AnnotateSessionArg {
269    /// Target session id.
270    session_id: String,
271    /// Score 1..=5.
272    #[serde(default)]
273    score: Option<u8>,
274    /// good | bad | interesting | bug | regression
275    #[serde(default)]
276    label: Option<String>,
277    #[serde(default)]
278    note: Option<String>,
279    #[serde(flatten)]
280    ws: WorkspaceArg,
281}
282
283#[derive(Clone, Debug)]
284pub struct KaizenMcp;
285
286#[tool_router]
287impl KaizenMcp {
288    #[tool(
289        name = "kaizen_capabilities",
290        description = "Read first: when to use summary vs metrics, sessions, retro, and other tools. No DB access; static help text only."
291    )]
292    async fn kaizen_capabilities(
293        &self,
294        Parameters(_): Parameters<WorkspaceArg>,
295    ) -> Result<CallToolResult, ErrorData> {
296        ok_str(MCP_CAPABILITIES.to_string())
297    }
298
299    #[tool(
300        name = "kaizen_ingest_hook",
301        description = "Ingest a hook event (same as `kaizen ingest hook`). Pass payload JSON, not stdin."
302    )]
303    async fn kaizen_ingest_hook(
304        &self,
305        Parameters(IngestHookArg {
306            ws,
307            source,
308            payload,
309        }): Parameters<IngestHookArg>,
310    ) -> Result<CallToolResult, ErrorData> {
311        let src = IngestSource::parse(&source)
312            .ok_or_else(|| ErrorData::invalid_params("source must be cursor or claude", None))?;
313        let w = resolve_ws(&ws)?;
314        run_blocking(move || ingest_hook_string(src, &payload, w)).await?;
315        ok_str(String::new())
316    }
317
318    #[tool(
319        name = "kaizen_sessions_list",
320        description = "List agent sessions in the workspace. Set json=true for structured output. Optional limit caps rows after sort (newest first). Use refresh=true for a full transcript rescan."
321    )]
322    async fn kaizen_sessions_list(
323        &self,
324        Parameters(WorkspaceJsonArg {
325            workspace,
326            all_workspaces,
327            json,
328            refresh,
329            limit,
330        }): Parameters<WorkspaceJsonArg>,
331    ) -> Result<CallToolResult, ErrorData> {
332        let w = opt_path(&workspace);
333        let lim = limit.map(|n| n as usize);
334        let t = run_blocking(move || {
335            cli::sessions_list_text(w.as_deref(), json, refresh, all_workspaces, lim)
336        })
337        .await?;
338        ok_str(t)
339    }
340
341    #[tool(
342        name = "kaizen_session_show",
343        description = "Show one session (kaizen sessions show)"
344    )]
345    async fn kaizen_session_show(
346        &self,
347        Parameters(SessionIdArg { ws, id }): Parameters<SessionIdArg>,
348    ) -> Result<CallToolResult, ErrorData> {
349        let w = resolve_ws(&ws)?;
350        let t = run_blocking(move || cli::session_show_text(&id, w.as_deref())).await?;
351        ok_str(t)
352    }
353
354    #[tool(
355        name = "mcp/search_sessions",
356        description = "BM25 full-text search over session events. Args match `kaizen sessions search`: query, since, agent, kind, limit, workspace."
357    )]
358    async fn search_sessions(
359        &self,
360        Parameters(SearchSessionsArg {
361            ws,
362            query,
363            since,
364            agent,
365            kind,
366            limit,
367        }): Parameters<SearchSessionsArg>,
368    ) -> Result<CallToolResult, ErrorData> {
369        let w = resolve_ws(&ws)?;
370        let (hits, fallback) = run_blocking(move || {
371            crate::shell::search::sessions_search_hits(
372                w.as_deref(),
373                &query,
374                since.as_deref(),
375                agent.as_deref(),
376                kind.as_deref(),
377                limit,
378            )
379        })
380        .await?;
381        Ok(CallToolResult::structured(serde_json::json!({
382            "fallback": fallback,
383            "count": hits.len(),
384            "hits": hits,
385        })))
386    }
387
388    #[tool(
389        name = "kaizen_summary",
390        description = "Roll up session counts, USD cost, top tools, by-agent/model. For **code** hotspots and slow tool p95, use `kaizen_metrics` instead. Set json=true to match `kaizen summary --json` (optional `cost_note` when sessions exist but stored cost rollup is zero)."
391    )]
392    async fn kaizen_summary(
393        &self,
394        Parameters(WorkspaceJsonArg {
395            workspace,
396            all_workspaces,
397            json,
398            refresh,
399            limit: _,
400        }): Parameters<WorkspaceJsonArg>,
401    ) -> Result<CallToolResult, ErrorData> {
402        let w = opt_path(&workspace);
403        let t = run_blocking(move || {
404            cli::summary_text(
405                w.as_deref(),
406                json,
407                refresh,
408                all_workspaces,
409                DataSource::Local,
410            )
411        })
412        .await?;
413        ok_str(t)
414    }
415
416    #[tool(
417        name = "kaizen_tui",
418        description = "Interactive TUI is not available via MCP. Returns guidance."
419    )]
420    async fn kaizen_tui(
421        &self,
422        Parameters(_): Parameters<WorkspaceArg>,
423    ) -> Result<CallToolResult, ErrorData> {
424        Ok(CallToolResult::structured_error(serde_json::json!({
425            "available": false,
426            "reason": "interactive",
427            "cli": "kaizen tui [ --workspace <path> ]"
428        })))
429    }
430
431    #[tool(
432        name = "kaizen_init",
433        description = "Idempotent workspace setup (kaizen init)"
434    )]
435    async fn kaizen_init(
436        &self,
437        Parameters(ws): Parameters<WorkspaceArg>,
438    ) -> Result<CallToolResult, ErrorData> {
439        let w = resolve_ws(&ws)?;
440        let t = run_blocking(move || init::init_text(w.as_deref())).await?;
441        ok_str(t)
442    }
443
444    #[tool(
445        name = "kaizen_insights",
446        description = "Session insights (kaizen insights)"
447    )]
448    async fn kaizen_insights(
449        &self,
450        Parameters(InsightsArg {
451            ws,
452            all_workspaces,
453            refresh,
454        }): Parameters<InsightsArg>,
455    ) -> Result<CallToolResult, ErrorData> {
456        let w = resolve_ws(&ws)?;
457        let t = run_blocking(move || {
458            insights::insights_text(w.as_deref(), all_workspaces, refresh, DataSource::Local)
459        })
460        .await?;
461        ok_str(t)
462    }
463
464    #[tool(
465        name = "kaizen_metrics",
466        description = "Repo + tool intelligence: hottest files, slow tools (p95), token/reasoning sinks, agent pain. Not for simple cost rollups — use `kaizen_summary` first."
467    )]
468    async fn kaizen_metrics(
469        &self,
470        Parameters(MetricsArg {
471            ws,
472            all_workspaces,
473            days,
474            json,
475            force,
476            refresh,
477        }): Parameters<MetricsArg>,
478    ) -> Result<CallToolResult, ErrorData> {
479        let w = resolve_ws(&ws)?;
480        let t = run_blocking(move || {
481            metrics::metrics_text(
482                w.as_deref(),
483                days,
484                json,
485                force,
486                all_workspaces,
487                refresh,
488                DataSource::Local,
489            )
490        })
491        .await?;
492        ok_str(t)
493    }
494
495    #[tool(
496        name = "kaizen_metrics_index",
497        description = "Rebuild repo snapshot index (kaizen metrics index)"
498    )]
499    async fn kaizen_metrics_index(
500        &self,
501        Parameters(MetricsIndexArg { ws, force }): Parameters<MetricsIndexArg>,
502    ) -> Result<CallToolResult, ErrorData> {
503        let w = resolve_ws(&ws)?;
504        let t = run_blocking(move || metrics::metrics_index_text(w.as_deref(), force)).await?;
505        ok_str(t)
506    }
507
508    #[tool(
509        name = "kaizen_sync_run",
510        description = "Flush outbox (kaizen sync run). Use once=true (default). Continuous mode is not supported."
511    )]
512    async fn kaizen_sync_run(
513        &self,
514        Parameters(SyncRunArg { ws, once }): Parameters<SyncRunArg>,
515    ) -> Result<CallToolResult, ErrorData> {
516        if !once {
517            return err_str(
518                "once=false (continuous sync daemon) is not supported over MCP. Run `kaizen sync run` in a shell, or pass once=true (default).".into(),
519            );
520        }
521        let w = resolve_ws(&ws)?;
522        let t = run_blocking(move || sync::sync_run_text(w.as_deref(), true)).await?;
523        ok_str(t)
524    }
525
526    #[tool(
527        name = "kaizen_sync_status",
528        description = "Outbox and sync health (kaizen sync status)"
529    )]
530    async fn kaizen_sync_status(
531        &self,
532        Parameters(ws): Parameters<WorkspaceArg>,
533    ) -> Result<CallToolResult, ErrorData> {
534        let w = resolve_ws(&ws)?;
535        let t = run_blocking(move || sync::sync_status_text(w.as_deref())).await?;
536        ok_str(t)
537    }
538
539    #[tool(
540        name = "kaizen_exp_new",
541        description = "Create experiment (kaizen exp new)"
542    )]
543    async fn kaizen_exp_new(
544        &self,
545        Parameters(ExpNewArg {
546            ws,
547            name,
548            hypothesis,
549            change,
550            metric,
551            bind,
552            duration_days,
553            target_pct,
554            control_commit,
555            treatment_commit,
556            control_branch,
557            treatment_branch,
558        }): Parameters<ExpNewArg>,
559    ) -> Result<CallToolResult, ErrorData> {
560        let w = resolve_ws(&ws)?;
561        let args = NewArgs {
562            name,
563            hypothesis,
564            change,
565            metric,
566            bind,
567            duration_days,
568            target_pct,
569            control_commit,
570            treatment_commit,
571            control_branch,
572            treatment_branch,
573        };
574        let t = run_blocking(move || exp::exp_new_text(w.as_deref(), args)).await?;
575        ok_str(t)
576    }
577
578    #[tool(
579        name = "kaizen_exp_list",
580        description = "List experiments (kaizen exp list)"
581    )]
582    async fn kaizen_exp_list(
583        &self,
584        Parameters(ws): Parameters<WorkspaceArg>,
585    ) -> Result<CallToolResult, ErrorData> {
586        let w = resolve_ws(&ws)?;
587        let t = run_blocking(move || exp::exp_list_text(w.as_deref())).await?;
588        ok_str(t)
589    }
590
591    #[tool(
592        name = "kaizen_exp_status",
593        description = "Show experiment (kaizen exp status)"
594    )]
595    async fn kaizen_exp_status(
596        &self,
597        Parameters(ExpIdArg { ws, id }): Parameters<ExpIdArg>,
598    ) -> Result<CallToolResult, ErrorData> {
599        let w = resolve_ws(&ws)?;
600        let t = run_blocking(move || exp::exp_status_text(w.as_deref(), &id)).await?;
601        ok_str(t)
602    }
603
604    #[tool(
605        name = "kaizen_exp_tag",
606        description = "Tag session variant (kaizen exp tag)"
607    )]
608    async fn kaizen_exp_tag(
609        &self,
610        Parameters(ExpTagArg {
611            ws,
612            id,
613            session,
614            variant,
615        }): Parameters<ExpTagArg>,
616    ) -> Result<CallToolResult, ErrorData> {
617        let w = resolve_ws(&ws)?;
618        let t =
619            run_blocking(move || exp::exp_tag_text(w.as_deref(), &id, &session, &variant)).await?;
620        ok_str(t)
621    }
622
623    #[tool(
624        name = "kaizen_exp_report",
625        description = "Experiment report (kaizen exp report). Optional refresh: true forces a full transcript rescan before computing the report."
626    )]
627    async fn kaizen_exp_report(
628        &self,
629        Parameters(ExpReportArg {
630            ws,
631            id,
632            json,
633            refresh,
634        }): Parameters<ExpReportArg>,
635    ) -> Result<CallToolResult, ErrorData> {
636        let w = resolve_ws(&ws)?;
637        let t =
638            run_blocking(move || exp::exp_report_text(w.as_deref(), &id, json, refresh)).await?;
639        ok_str(t)
640    }
641
642    #[tool(
643        name = "kaizen_exp_conclude",
644        description = "Conclude experiment (kaizen exp conclude)"
645    )]
646    async fn kaizen_exp_conclude(
647        &self,
648        Parameters(ExpIdArg { ws, id }): Parameters<ExpIdArg>,
649    ) -> Result<CallToolResult, ErrorData> {
650        let w = resolve_ws(&ws)?;
651        let t = run_blocking(move || exp::exp_conclude_text(w.as_deref(), &id)).await?;
652        ok_str(t)
653    }
654
655    #[tool(
656        name = "kaizen_exp_start",
657        description = "Start experiment — transition Draft → Running (kaizen exp start)"
658    )]
659    async fn kaizen_exp_start(
660        &self,
661        Parameters(ExpIdArg { ws, id }): Parameters<ExpIdArg>,
662    ) -> Result<CallToolResult, ErrorData> {
663        let w = resolve_ws(&ws)?;
664        let t = run_blocking(move || exp::exp_start_text(w.as_deref(), &id)).await?;
665        ok_str(t)
666    }
667
668    #[tool(
669        name = "kaizen_exp_archive",
670        description = "Archive experiment — transition Concluded → Archived (kaizen exp archive)"
671    )]
672    async fn kaizen_exp_archive(
673        &self,
674        Parameters(ExpIdArg { ws, id }): Parameters<ExpIdArg>,
675    ) -> Result<CallToolResult, ErrorData> {
676        let w = resolve_ws(&ws)?;
677        let t = run_blocking(move || exp::exp_archive_text(w.as_deref(), &id)).await?;
678        ok_str(t)
679    }
680
681    #[tool(
682        name = "kaizen_retro",
683        description = "Heuristic retro report (kaizen retro). Prefer json=true for machine parsing."
684    )]
685    async fn kaizen_retro(
686        &self,
687        Parameters(RetroArg {
688            ws,
689            days,
690            dry_run,
691            json,
692            force,
693            refresh,
694        }): Parameters<RetroArg>,
695    ) -> Result<CallToolResult, ErrorData> {
696        let w = resolve_ws(&ws)?;
697        let t = run_blocking(move || {
698            retro::retro_stdout(
699                w.as_deref(),
700                days,
701                dry_run,
702                json,
703                force,
704                refresh,
705                DataSource::Local,
706            )
707        })
708        .await?;
709        ok_str(t)
710    }
711
712    #[tool(
713        name = "kaizen_annotate_session",
714        description = "Attach human feedback (score 1-5, label, free-text note) to a session."
715    )]
716    async fn kaizen_annotate_session(
717        &self,
718        Parameters(AnnotateSessionArg {
719            session_id,
720            score,
721            label,
722            note,
723            ws,
724        }): Parameters<AnnotateSessionArg>,
725    ) -> Result<CallToolResult, ErrorData> {
726        use crate::feedback::types::FeedbackLabel;
727        let parsed_label = match label.as_deref() {
728            Some(s) => {
729                let l = FeedbackLabel::from_str_opt(s);
730                if l.is_none() {
731                    return Err(ErrorData::invalid_params(
732                        format!("unknown label: {s}"),
733                        None,
734                    ));
735                }
736                l
737            }
738            None => None,
739        };
740        let w = resolve_ws(&ws)?;
741        run_blocking(move || {
742            crate::shell::feedback::cmd_sessions_annotate(
743                &session_id,
744                score,
745                parsed_label,
746                note,
747                w.as_deref(),
748            )
749        })
750        .await?;
751        ok_str("annotated".into())
752    }
753
754    #[tool(
755        name = "get_session_span_tree",
756        description = "Return the nested tool-span tree for a session. Each node carries tool name, status, subtree cost, depth, and children. Use json=true for structured output."
757    )]
758    async fn get_session_span_tree(
759        &self,
760        Parameters(GetSpanTreeArg { ws, id, json }): Parameters<GetSpanTreeArg>,
761    ) -> Result<CallToolResult, ErrorData> {
762        let w = resolve_ws(&ws)?;
763        let t = run_blocking(move || {
764            crate::shell::cli::cmd_sessions_tree_text(&id, 999, json, w.as_deref())
765        })
766        .await?;
767        ok_str(t)
768    }
769}
770
771#[tool_handler(
772    name = "kaizen",
773    version = "0.1.0",
774    instructions = "kaizen: local agent telemetry. Call `kaizen_capabilities` first if unsure. Cost/volume: `kaizen_summary`. Code hotspots and slow tools: `kaizen_metrics`. Most CLI workflows are here; shell-only: doctor, guidance, gc, completions, proxy, telemetry. Workspace defaults to the server cwd. `kaizen_tui` is interactive CLI-only. `kaizen_sync_run` supports once=true only."
775)]
776impl ServerHandler for KaizenMcp {}