Skip to main content

headless_lms_mcp/handlers/
history.rs

1//! History of agent write calls and reverting one of them.
2
3use 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
26/// Handler of the `get_history` tool.
27pub struct GetHistory;
28/// Handler of the `revert_change` tool.
29pub struct RevertChange;
30
31const DEFAULT_HISTORY_LIMIT: u32 = 20;
32const MAX_HISTORY_LIMIT: u32 = 100;
33
34/// Whether turning the view's staged state into `after` changes what only the settings tool
35/// writes. Decided by content, since a revert of a revert is recorded under `revert_change`.
36fn 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}