1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5use std::time::Duration;
6
7use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_str};
8use lean_ctx::tool_defs::tool_def;
9
10fn handle_compact_action(
17 action: &str,
18 value: Option<&str>,
19 args: &Map<String, Value>,
20) -> ToolOutput {
21 let session = lean_ctx::core::activity::session_id_from_env();
22 let text = if action == "restore" {
23 match session.as_deref() {
24 Some(session) if lean_ctx::core::compact_directive::clear(session) => {
25 "Compaction cleared — the next request carries the full original history again."
26 .to_string()
27 }
28 _ => "No active compaction to restore.".to_string(),
29 }
30 } else if session.is_none() && value.is_some_and(|v| !v.is_empty()) {
31 "Compaction not armed: this MCP server has no app session id \
32 (LEAN_CTX_SESSION_ID), so no proxy can key a compaction to your session. \
33 Your context stays as it is."
34 .to_string()
35 } else if let Some(session) = session.as_deref()
36 && value.is_some_and(|v| !v.is_empty())
37 && !lean_ctx::core::compact_directive::is_carried(session)
38 {
39 "Compaction not armed: no proxy carries this session (no request with its \
40 session id reached a LeanCTX proxy recently), and only a proxy can shorten \
41 the conversation. Run the agent through the proxy, then compact again. \
42 Your context stays as it is."
43 .to_string()
44 } else {
45 let session = session.unwrap_or_default();
46 let keep = args
47 .get("keep_recent_turns")
48 .and_then(serde_json::Value::as_u64)
49 .map_or(6, |n| n as usize)
50 .max(4);
51 match value {
52 None | Some("") => "compact requires value = the summary that replaces the \
53 compacted span. Write it exhaustive-but-lean: every file path, decision, \
54 constraint and finding that keeps context integrity; quote short user \
55 requests verbatim; drop dead-end exploration."
56 .to_string(),
57 Some(summary) => {
58 match lean_ctx::core::compact_directive::create(&session, keep, summary.to_string())
59 {
60 Ok(()) => format!(
61 "Compaction armed (keeps last {keep} user turns verbatim). It applies \
62 to your NEXT request onward, only outside the provider-cached \
63 prefix, at tool-pair-safe boundaries. Undo any time: \
64 ctx_session action=\"restore\"."
65 ),
66 Err(reason) => reason,
67 }
68 }
69 }
70 };
71 ToolOutput {
72 text,
73 original_tokens: 0,
74 saved_tokens: 0,
75 mode: Some(action.to_string()),
76 path: None,
77 changed: false,
78 shell_outcome: None,
79 content_blocks: None,
80 }
81}
82
83pub struct CtxSessionTool;
84
85const SESSION_LOCK_BUDGET: Duration = Duration::from_millis(250);
88
89fn session_matches_snapshot(
90 live: &lean_ctx::core::session::SessionState,
91 snapshot: &lean_ctx::core::session::SessionState,
92) -> bool {
93 live.id == snapshot.id
94 && live.version == snapshot.version
95 && live.project_root == snapshot.project_root
96 && live.shell_cwd == snapshot.shell_cwd
97 && live.extra_roots == snapshot.extra_roots
98}
99
100impl McpTool for CtxSessionTool {
101 fn name(&self) -> &'static str {
102 "ctx_session"
103 }
104
105 fn tool_def(&self) -> Tool {
106 tool_def(
107 "ctx_session",
108 "Session memory. save at session end, load at start, status = snapshot;\n\
109 task|finding|decision record progress (value=text).\n\
110 ANTIPATTERN: permanent project knowledge → ctx_knowledge.",
111 json!({
112 "type": "object",
113 "properties": {
114 "action": {
115 "type": "string",
116 "description": "status|handoff|load|save|task|finding|decision|compact|restore|list|… (invalid action lists all)"
117 },
118 "value": { "type": "string" },
119 "tag": { "type": "string", "enum": ["note", "dead_end"], "description": "finding: dead_end = tried and abandoned" },
120 "keep_recent_turns": { "type": "integer", "description": "compact: trailing user turns kept verbatim (default 6)" },
121 "session_id": { "type": "string", "description": "Omit for your own session" }
122 },
123 "required": ["action"]
124 }),
125 )
126 }
127
128 fn handle(
129 &self,
130 args: &Map<String, Value>,
131 ctx: &ToolContext,
132 ) -> Result<ToolOutput, ErrorData> {
133 let action = get_str(args, "action")
134 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
135 let value = get_str(args, "value");
136 let sid = get_str(args, "session_id");
137 let format = get_str(args, "format");
138 let path = get_str(args, "path");
139 let write = get_bool(args, "write").unwrap_or(false);
140 let privacy = get_str(args, "privacy");
141 let terse = get_bool(args, "terse");
142 let dead_end = match get_str(args, "tag").as_deref() {
143 None | Some("note") => false,
144 Some("dead_end") => true,
145 Some(_) => {
146 return Err(ErrorData::invalid_params(
147 "tag must be note or dead_end",
148 None,
149 ));
150 }
151 };
152
153 if action == "restore" {
156 return Ok(handle_compact_action(&action, value.as_deref(), args));
157 }
158 if action == "compact" {
159 let Some(summary) = value.as_deref().filter(|summary| !summary.is_empty()) else {
160 return Ok(handle_compact_action(&action, value.as_deref(), args));
161 };
162 let session_handle = ctx
163 .session
164 .as_ref()
165 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
166 let Some(snapshot) = crate::server::bounded_lock::read_for(
167 session_handle,
168 "ctx_session compact admission snapshot",
169 SESSION_LOCK_BUDGET,
170 )
171 .map(|session| session.clone()) else {
172 return Err(ErrorData::internal_error(
173 "session is busy; retry the operation",
174 None,
175 ));
176 };
177 let admitted =
178 lean_ctx::core::session::admission::admit_storable_session_text(&snapshot, summary)
179 .map_err(|_| {
180 ErrorData::invalid_params(
181 "compact summary withheld by current policy",
182 None,
183 )
184 })?;
185 return Ok(handle_compact_action(&action, Some(&admitted), args));
186 }
187
188 let tool_calls_handle = ctx
189 .tool_calls
190 .as_ref()
191 .ok_or_else(|| ErrorData::internal_error("tool_calls not available", None))?;
192 let call_durations: Vec<(String, u64)> = crate::server::bounded_lock::read_for(
193 tool_calls_handle,
194 "ctx_session tool-call snapshot",
195 SESSION_LOCK_BUDGET,
196 )
197 .map_or_else(Vec::new, |tc| {
198 tc.iter().map(|c| (c.tool.clone(), c.duration_ms)).collect()
199 });
200 let agent_id = ctx
201 .agent_id
202 .as_ref()
203 .and_then(|agent_id| agent_id.try_read().ok().and_then(|id| id.clone()));
204
205 let result = if let Some(result) = crate::tools::ctx_session::handle_without_session(
206 &action,
207 value.as_deref(),
208 sid.as_deref(),
209 ) {
210 result
211 } else {
212 let session_handle = ctx
213 .session
214 .as_ref()
215 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
216
217 if action == "save" {
218 let Some(snapshot) = crate::server::bounded_lock::read_for(
219 session_handle,
220 "ctx_session save snapshot",
221 SESSION_LOCK_BUDGET,
222 )
223 .map(|session| session.clone()) else {
224 return Err(ErrorData::internal_error(
225 "session is busy; retry the operation",
226 None,
227 ));
228 };
229 let result =
230 match lean_ctx::core::session::SessionState::save_shared_sync(session_handle) {
231 Err(error) => format!("Save failed: {error}"),
232 Ok(receipt) if receipt.current => {
233 format!("Session {} saved (v{}).", receipt.id, receipt.version)
234 }
235 Ok(receipt) => format!(
236 "Session {} snapshot v{} saved; newer session changes remain pending.",
237 receipt.id, receipt.version
238 ),
239 };
240 lean_ctx::core::session::admission::admit_tool_value(&snapshot, &result)
241 .unwrap_or_else(|_| {
242 "ERROR: session save result withheld by current policy.".into()
243 })
244 } else if action == "reset" {
245 let Some(snapshot) = crate::server::bounded_lock::read_for(
246 session_handle,
247 "ctx_session reset snapshot",
248 SESSION_LOCK_BUDGET,
249 )
250 .map(|session| session.clone()) else {
251 return Err(ErrorData::internal_error(
252 "session is busy; retry the operation",
253 None,
254 ));
255 };
256 let output =
257 match lean_ctx::core::session::SessionState::save_shared_sync(session_handle) {
258 Err(error) => {
259 format!("Reset refused: current session was not saved: {error}")
260 }
261 Ok(receipt)
262 if !receipt.current
263 || receipt.id != snapshot.id
264 || receipt.version != snapshot.version =>
265 {
266 "Reset refused: session changed while it was being saved; retry.".into()
267 }
268 Ok(_) => {
269 let Some(mut live) = crate::server::bounded_lock::write_for(
270 session_handle,
271 "ctx_session reset commit",
272 SESSION_LOCK_BUDGET,
273 ) else {
274 return Err(ErrorData::internal_error(
275 "session is busy; retry the operation",
276 None,
277 ));
278 };
279 if !session_matches_snapshot(&live, &snapshot)
280 || !std::sync::Arc::ptr_eq(&live.save_gate, &snapshot.save_gate)
281 {
282 return Err(ErrorData::internal_error(
283 "session changed during reset; retry the operation",
284 None,
285 ));
286 }
287 crate::tools::ctx_session::reset_after_save(&mut live)
288 }
289 };
290 lean_ctx::core::session::admission::admit_tool_value(&snapshot, &output)
291 .unwrap_or_else(|_| "ERROR: reset result withheld by current policy.".into())
292 } else if action == "load" {
293 let Some(snapshot) = crate::server::bounded_lock::read_for(
294 session_handle,
295 "ctx_session load snapshot",
296 SESSION_LOCK_BUDGET,
297 )
298 .map(|session| session.clone()) else {
299 return Err(ErrorData::internal_error(
300 "session is busy; retry the operation",
301 None,
302 ));
303 };
304 let (candidate, output) =
305 crate::tools::ctx_session::prepare_load(&snapshot, sid.as_deref()).map_err(
306 |_| {
307 ErrorData::invalid_params(
308 "session load denied by current project scope or content policy",
309 None,
310 )
311 },
312 )?;
313 let Some(mut live) = crate::server::bounded_lock::write_for(
314 session_handle,
315 "ctx_session load commit",
316 SESSION_LOCK_BUDGET,
317 ) else {
318 return Err(ErrorData::internal_error(
319 "session is busy; retry the operation",
320 None,
321 ));
322 };
323 if !session_matches_snapshot(&live, &snapshot) {
324 return Err(ErrorData::internal_error(
325 "session changed during load; retry the operation",
326 None,
327 ));
328 }
329 *live = candidate;
330 output
331 } else if matches!(action.as_str(), "export" | "resume" | "snapshot") {
332 let Some(snapshot) = crate::server::bounded_lock::read_for(
333 session_handle,
334 "ctx_session snapshot",
335 SESSION_LOCK_BUDGET,
336 )
337 .map(|session| session.clone()) else {
338 return Err(ErrorData::internal_error(
339 "session is busy; retry the operation",
340 None,
341 ));
342 };
343 crate::tools::ctx_session::handle_snapshot_read_action(
344 &snapshot,
345 &action,
346 value.as_deref(),
347 crate::tools::ctx_session::SessionToolOptions {
348 format: format.as_deref(),
349 path: path.as_deref(),
350 write,
351 privacy: privacy.as_deref(),
352 terse,
353 agent_id: agent_id.as_deref(),
354 },
355 )
356 .expect("snapshot action is explicitly handled")
357 } else if matches!(action.as_str(), "status" | "show" | "handoff") {
358 let Some(snapshot) = crate::server::bounded_lock::read_for(
359 session_handle,
360 "ctx_session read",
361 SESSION_LOCK_BUDGET,
362 )
363 .map(|session| session.clone()) else {
364 return Err(ErrorData::internal_error(
365 "session is busy; retry the operation",
366 None,
367 ));
368 };
369 crate::tools::ctx_session::handle_read_only(&snapshot, &action)
370 .expect("read-only action is explicitly handled")
371 } else if matches!(action.as_str(), "task" | "finding" | "decision" | "list") {
372 let Some(snapshot) = crate::server::bounded_lock::read_for(
373 session_handle,
374 "ctx_session admitted action snapshot",
375 SESSION_LOCK_BUDGET,
376 )
377 .map(|session| session.clone()) else {
378 return Err(ErrorData::internal_error(
379 "session is busy; retry the operation",
380 None,
381 ));
382 };
383 let prepared = crate::tools::ctx_session::prepare_admitted_action(
384 &snapshot,
385 &call_durations,
386 &action,
387 value.as_deref(),
388 sid.as_deref(),
389 crate::tools::ctx_session::SessionToolOptions {
390 format: format.as_deref(),
391 path: path.as_deref(),
392 write,
393 privacy: privacy.as_deref(),
394 terse,
395 agent_id: agent_id.as_deref(),
396 },
397 )
398 .map_err(|_| {
399 ErrorData::invalid_params(
400 "session action withheld by current project or content policy",
401 None,
402 )
403 })?;
404 if matches!(action.as_str(), "task" | "finding" | "decision") {
405 let candidate = prepared.candidate;
406 let output = prepared.output;
407 let Some(mut live) = crate::server::bounded_lock::write_for(
408 session_handle,
409 "ctx_session admitted action commit",
410 SESSION_LOCK_BUDGET,
411 ) else {
412 return Err(ErrorData::internal_error(
413 "session is busy; retry the operation",
414 None,
415 ));
416 };
417 if !session_matches_snapshot(&live, &snapshot) {
418 return Err(ErrorData::internal_error(
419 "session changed during content admission; retry the operation",
420 None,
421 ));
422 }
423 if !prepared.store_admission.is_current() {
424 return Err(ErrorData::internal_error(
425 "session policy changed during content admission; retry the operation",
426 None,
427 ));
428 }
429 *live = candidate.clone();
430 drop(live);
431 if dead_end
434 && action == "finding"
435 && let Some(finding) = candidate.findings.last()
436 {
437 lean_ctx_hive::capture::episode::note_dead_end(
438 &candidate.id,
439 &finding.summary,
440 );
441 }
442 lean_ctx::core::session::admission::admit_tool_value(&candidate, &output)
443 .unwrap_or_else(|_| "[lean-ctx gateway: session content withheld]".into())
444 } else {
445 if !prepared.store_admission.is_current() {
446 return Err(ErrorData::internal_error(
447 "session policy changed during content admission; retry the operation",
448 None,
449 ));
450 }
451 prepared.output
452 }
453 } else {
454 let Some(mut session) = crate::server::bounded_lock::write_for(
455 session_handle,
456 "ctx_session write",
457 SESSION_LOCK_BUDGET,
458 ) else {
459 return Err(ErrorData::internal_error(
460 "session is busy; retry the operation",
461 None,
462 ));
463 };
464 crate::tools::ctx_session::handle(
465 &mut session,
466 &call_durations,
467 &action,
468 value.as_deref(),
469 sid.as_deref(),
470 crate::tools::ctx_session::SessionToolOptions {
471 format: format.as_deref(),
472 path: path.as_deref(),
473 write,
474 privacy: privacy.as_deref(),
475 terse,
476 agent_id: agent_id.as_deref(),
477 },
478 )
479 }
480 };
481
482 Ok(ToolOutput {
483 text: result,
484 original_tokens: 0,
485 saved_tokens: 0,
486 mode: Some(action),
487 path: None,
488 changed: false,
489 shell_outcome: None,
490 content_blocks: None,
491 })
492 }
493
494 }