headless_lms_mcp/handlers/
history.rs1use async_trait::async_trait;
4use headless_lms_base::error::backend_error::BackendError;
5use headless_lms_models::{
6 ModelErrorType,
7 agent_changeset_calls::{self, CallHistoryEntry},
8 entity_history,
9 library::agent_changesets::{
10 RevertPlan,
11 course_state::{CourseState, CourseView},
12 plan_revert,
13 },
14};
15use serde_json::{Value, json};
16
17use crate::access::{authorize_course, connection, open_course};
18use crate::snapshots;
19use crate::staging::{self, WriteMode, WriteRequest, WriteSource};
20use crate::state::McpState;
21use crate::tools::args::{GetHistoryArgs, IdArgument, RevertChangeArgs};
22use crate::tools::{
23 McpScope, RequestContext, ToolError, ToolErrorCode, ToolHandler, ToolOutcome, ToolResult,
24};
25
26pub struct GetHistory;
28pub struct RevertChange;
30
31const DEFAULT_HISTORY_LIMIT: u32 = 20;
32const MAX_HISTORY_LIMIT: u32 = 100;
33
34fn changes_settings(view: &CourseView, after: &CourseState) -> bool {
37 let mut written = view.staged.clone();
38 snapshots::apply_settings(
39 &mut written,
40 &snapshots::settings_document(after, &view.course),
41 );
42 written != view.staged
43}
44
45fn call_json(call: CallHistoryEntry) -> Value {
46 json!({
47 "call": call.id,
48 "tool": call.tool_name,
49 "summary": call.summary,
50 "created_at": call.created_at,
51 "via": call.client_name,
52 "changeset": call.changeset_id,
53 "changeset_status": call.changeset_status,
54 "revert_of": call.revert_of_call_id,
55 "entities": call.entities,
56 })
57}
58
59#[async_trait(?Send)]
60impl ToolHandler<McpState> for GetHistory {
61 type Args = GetHistoryArgs;
62
63 async fn call(
64 &self,
65 state: &McpState,
66 ctx: &RequestContext,
67 args: GetHistoryArgs,
68 ) -> Result<ToolOutcome, ToolError> {
69 let limit = args
70 .limit
71 .unwrap_or(DEFAULT_HISTORY_LIMIT)
72 .clamp(1, MAX_HISTORY_LIMIT);
73 let entity = args.entity.map(IdArgument::id);
74 let course_id = args.course.id();
75 let mut conn = connection(state).await?;
76 let user_id = ctx.principal.actor.user_id;
77 authorize_course(&mut conn, user_id, course_id).await?;
78 let calls = agent_changeset_calls::get_history(
79 &mut conn,
80 user_id,
81 course_id,
82 entity,
83 i64::from(limit),
84 )
85 .await?;
86 let items: Vec<Value> = calls.into_iter().map(call_json).collect();
87 let mut result = json!({"calls": items});
88 if let Some(entity_id) = entity
89 && let Some(version) =
90 entity_history::latest_version(&mut conn, entity_id, Some(course_id)).await?
91 {
92 result["last_published_change"] = json!({
93 "at": version.created_at,
94 "by_user": version.author_user_id,
95 "via": version.agent_client_name.unwrap_or_else(|| "web".to_string()),
96 });
97 }
98 Ok(ToolResult::success(result).into())
99 }
100}
101
102#[async_trait(?Send)]
103impl ToolHandler<McpState> for RevertChange {
104 type Args = RevertChangeArgs;
105
106 async fn call(
107 &self,
108 state: &McpState,
109 ctx: &RequestContext,
110 args: RevertChangeArgs,
111 ) -> Result<ToolOutcome, ToolError> {
112 let call_id = args.call.id();
113 let view = open_course(state, ctx, args.course.id()).await?;
114 let course_id = view.course.id;
115 let not_found = || ToolError::NotFound {
116 message: format!(
117 "you made no call {call_id} in course {course_id}; get_history lists your calls"
118 ),
119 };
120 let plan = plan_revert(
121 &mut *connection(state).await?,
122 &view,
123 call_id,
124 ctx.principal.actor.user_id,
125 )
126 .await
127 .map_err(|error| match error.error_type() {
128 ModelErrorType::NotFound | ModelErrorType::RecordNotFound => not_found(),
129 _ => error.into(),
130 })?;
131 let (tool_name, entities, current) = match plan {
132 RevertPlan::Ready {
133 tool_name,
134 entities,
135 current,
136 } => (tool_name, entities, current),
137 RevertPlan::Conflicts(conflicts) => {
138 return Err(ToolError::refused(
139 ToolErrorCode::RevertConflict,
140 "Nothing was staged: these entities changed again after the call, so reverting it would undo later changes too.",
141 json!({"conflicts": conflicts}),
142 ));
143 }
144 };
145 let mut after = view.staged.clone();
146 for entity in &entities {
147 after.set_entity(entity.key(), entity.after());
148 }
149 if changes_settings(&view, &after) {
150 ctx.require_scope(McpScope::SettingsWrite)?;
151 }
152 let report = staging::commit(
153 state,
154 ctx,
155 &view,
156 after,
157 WriteRequest {
158 source: WriteSource::Revert {
159 call_id,
160 writes: entities,
161 read_states: current,
162 },
163 ..WriteRequest::new(
164 json!({"call": call_id}),
165 format!("revert of {tool_name} call {call_id}"),
166 WriteMode::from_dry_run(args.dry_run),
167 )
168 },
169 )
170 .await?;
171 Ok(report
172 .into_result(&json!({"reverted_call": call_id}))
173 .into())
174 }
175}