1use async_trait::async_trait;
4use headless_lms_authorization::{fetch_user_roles, is_user_global_admin};
5use headless_lms_base::error::backend_error::BackendError;
6use headless_lms_models::{
7 ModelErrorType,
8 agent_changeset_entities::{self, AgentChangesetEntityOperation},
9 agent_changesets::{self, AgentChangeset, AgentChangesetStatus},
10 courses::{self, Course},
11 error::TryToOptional,
12 library::agent_changesets::{
13 ChangedSinceReview, ChangesetSummary, DiscardOutcome, DiscardRejection, PublishOptions,
14 PublishOutcome, PublishRejection, RiskBaseline, changed_page_links, discard,
15 discard_entities, get_summaries, mint_preview_token, preview_path, publish,
16 },
17 library::entity_snapshots::{ContentEntityType, EntityKey},
18 pages::{self, Page},
19};
20use serde_json::{Value, json};
21use sqlx::PgConnection;
22use uuid::Uuid;
23
24use crate::access::{authorize_course, connection, deleted_in_staged_changes, editable_courses};
25use crate::explanations::{ExplainedEntity, explain_blockers};
26use crate::hints;
27use crate::page_readiness;
28use crate::state::McpState;
29use crate::tools::args::{
30 DiscardChangesetArgs, GetPreviewLinkArgs, IdArgument, ListChangesetsArgs, PublishChangesetArgs,
31};
32use crate::tools::{
33 ConfirmationRequest, RequestContext, ToolError, ToolErrorCode, ToolHandler, ToolOutcome,
34 ToolResult,
35};
36use headless_lms_models::library::agent_changesets::course_state::{
37 CourseParts, CourseView, load_view,
38};
39
40pub struct ListChangesets;
42pub struct PublishChangeset;
44pub struct DiscardChangeset;
46pub struct GetPreviewLink;
48
49const RECENT_CLOSED_CHANGESETS: i64 = 10;
51
52fn explained(entities: Vec<impl Into<ExplainedEntity>>) -> Vec<ExplainedEntity> {
53 entities.into_iter().map(Into::into).collect()
54}
55
56fn summary_json(state: &McpState, summary: ChangesetSummary) -> Value {
57 let changeset = &summary.changeset;
58 json!({
59 "id": changeset.id,
60 "course": changeset.course_id,
61 "status": changeset.status,
62 "revision": changeset.revision,
63 "created_at": changeset.created_at,
64 "expires_at": changeset.is_open().then_some(changeset.expires_at),
65 "published_at": changeset.published_at,
66 "is_expiring_soon": summary.is_expiring_soon,
67 "has_risky_changes": summary.has_risky_changes,
68 "review_url": state.review_url(changeset.id),
69 "entities": explained(summary.entities),
70 })
71}
72
73#[async_trait(?Send)]
74impl ToolHandler<McpState> for ListChangesets {
75 type Args = ListChangesetsArgs;
76
77 async fn call(
78 &self,
79 state: &McpState,
80 ctx: &RequestContext,
81 args: ListChangesetsArgs,
82 ) -> Result<ToolOutcome, ToolError> {
83 let user_id = ctx.principal.actor.user_id;
84 let mut conn = connection(state).await?;
85 let course_ids = match &args.course {
86 Some(course) => {
87 let course_id = course.id();
88 authorize_course(&mut conn, user_id, course_id).await?;
89 vec![course_id]
90 }
91 None => {
92 let roles = fetch_user_roles(&mut conn, Some(user_id)).await?;
93 let course_ids =
94 agent_changesets::get_course_ids_by_user(&mut conn, user_id).await?;
95 let courses = courses::get_by_ids(&mut conn, &course_ids).await?;
96 editable_courses(&roles, courses)
97 .into_iter()
98 .map(|course| course.id)
99 .collect()
100 }
101 };
102 let listed = agent_changesets::get_open_and_recent_by_user(
103 &mut conn,
104 user_id,
105 &course_ids,
106 RECENT_CLOSED_CHANGESETS,
107 )
108 .await?;
109 let (open, closed): (Vec<ChangesetSummary>, Vec<ChangesetSummary>) =
110 get_summaries(&mut conn, listed)
111 .await?
112 .into_iter()
113 .partition(|summary| summary.changeset.is_open());
114 let as_json = |summaries: Vec<ChangesetSummary>| -> Vec<Value> {
115 summaries
116 .into_iter()
117 .map(|summary| summary_json(state, summary))
118 .collect()
119 };
120 Ok(ToolResult::success(json!({"open": as_json(open), "recent": as_json(closed)})).into())
121 }
122}
123
124async fn own_changeset(
126 conn: &mut PgConnection,
127 ctx: &RequestContext,
128 course_id: Uuid,
129 changeset_id: Uuid,
130) -> Result<(Course, AgentChangeset), ToolError> {
131 let course = authorize_course(conn, ctx.principal.actor.user_id, course_id).await?;
132 let not_found = || ToolError::NotFound {
133 message: format!(
134 "you have no changeset {changeset_id} in course {course_id}; list_changesets lists yours"
135 ),
136 };
137 let changeset =
138 agent_changesets::get_by_id_for_user(conn, changeset_id, ctx.principal.actor.user_id)
139 .await
140 .map_err(|error| match error.error_type() {
141 ModelErrorType::NotFound | ModelErrorType::RecordNotFound => not_found(),
142 _ => error.into(),
143 })?;
144 if changeset.course_id != course_id {
145 return Err(not_found());
146 }
147 Ok((course, changeset))
148}
149
150fn not_open_result(
152 course: &Course,
153 changeset_id: Uuid,
154 status: AgentChangesetStatus,
155) -> ToolResult {
156 let mut result = ToolResult::success(json!({
157 "changeset": changeset_id,
158 "status": status,
159 "published": status == AgentChangesetStatus::Published,
160 "message": match status {
161 AgentChangesetStatus::Published => "The changeset is already published.",
162 AgentChangesetStatus::Discarded => "The changeset was discarded; nothing of it was published.",
163 AgentChangesetStatus::Expired => "The changeset expired; nothing of it was published.",
164 AgentChangesetStatus::Open => "The changeset is still open.",
165 },
166 }));
167 if status == AgentChangesetStatus::Published {
168 result = result.with_hint(hints::published(changeset_id, course.is_draft));
169 }
170 result
171}
172
173fn check_pages_ready(view: &CourseView) -> Result<(), ToolError> {
176 let mut untitled = Vec::new();
177 let mut with_placeholders = Vec::new();
178 for page in page_readiness::pages_with_content_changes(&view.staged, &view.live) {
179 if !page_readiness::has_page_title(&page.content) {
180 untitled.push(page);
181 }
182 let placeholders = page_readiness::placeholder_texts(&page.content);
183 if !placeholders.is_empty() {
184 with_placeholders.push((page, placeholders));
185 }
186 }
187 if untitled.is_empty() && with_placeholders.is_empty() {
188 return Ok(());
189 }
190 let mut problems = Vec::new();
191 if !untitled.is_empty() {
192 let pages: Vec<String> = untitled
193 .iter()
194 .map(|page| format!("\"{}\" ({})", page.title, page.id))
195 .collect();
196 problems.push(format!(
197 "these pages have no page title: {}. Add a moocfi/hero-section with the page title as the first block (moocfi/landing-page-hero-section on the course front page); it is the only h1 course pages allow",
198 pages.join(", ")
199 ));
200 }
201 if !with_placeholders.is_empty() {
202 let pages: Vec<String> = with_placeholders
203 .iter()
204 .map(|(page, placeholders)| {
205 let blocks: Vec<String> = placeholders
206 .iter()
207 .map(|p| format!("{} {} \"{}\"", p.block_name, p.block, p.text))
208 .collect();
209 format!("\"{}\" ({}): {}", page.title, page.id, blocks.join(", "))
210 })
211 .collect();
212 problems.push(format!(
213 "these pages still hold placeholder text from the default page template: {}. Replace it with the course's own text or remove those blocks",
214 pages.join("; ")
215 ));
216 }
217 Err(ToolError::refused(
218 ToolErrorCode::PagesNotReady,
219 format!(
220 "Nothing was published: {}. Fix the pages with patch_page_content, then publish again.",
221 problems.join("; and ")
222 ),
223 json!({
224 "pages_without_title": untitled.iter().map(|page| page.id).collect::<Vec<_>>(),
225 "pages_with_placeholders": with_placeholders
226 .iter()
227 .map(|(page, placeholders)| json!({"page": page.id, "placeholders": placeholders}))
228 .collect::<Vec<_>>(),
229 }),
230 ))
231}
232
233#[async_trait(?Send)]
234impl ToolHandler<McpState> for PublishChangeset {
235 type Args = PublishChangesetArgs;
236 const ASKS_FOR_CONFIRMATION: bool = true;
237
238 async fn call(
239 &self,
240 state: &McpState,
241 ctx: &RequestContext,
242 args: PublishChangesetArgs,
243 ) -> Result<ToolOutcome, ToolError> {
244 let mut conn = connection(state).await?;
245 let (course, changeset) =
246 own_changeset(&mut conn, ctx, args.course.id(), args.changeset.id()).await?;
247 if !changeset.is_open() {
248 return Ok(not_open_result(&course, changeset.id, changeset.status).into());
249 }
250 let view = load_view(
251 &mut conn,
252 ctx.principal.actor.user_id,
253 course,
254 CourseParts::All,
255 )
256 .await?;
257 check_pages_ready(&view)?;
258 let rendering_change_count =
259 page_readiness::pages_with_content_changes(&view.staged, &view.live).count();
260 let review_url = state.review_url(changeset.id);
261 let can_add_global_admin_only_exercise_services =
262 is_user_global_admin(&mut conn, ctx.principal.actor.user_id).await?;
263 let spec_fetcher = (state.exercise_services.spec_fetcher)();
264 let outcome = publish(
265 &mut conn,
266 changeset.id,
267 PublishOptions {
268 can_add_global_admin_only_exercise_services,
269 reviewed_revision: None,
270 seen_revision: ctx.retry.is_none().then_some(args.revision),
273 },
274 &state.app_conf,
275 &*spec_fetcher,
276 &*state.exercise_services.fetch_service_info,
277 )
278 .await?;
279 let rejection = match outcome {
280 PublishOutcome::Published {
281 changeset,
282 redirects,
283 } => {
284 let mut result = ToolResult::success(json!({
285 "changeset": changeset.id,
286 "published": true,
287 "published_at": changeset.published_at,
288 "redirects": redirects,
289 }))
290 .with_hint(hints::published(changeset.id, view.course.is_draft));
291 if rendering_change_count > 0 {
292 result = result
293 .with_hint(hints::published_rendering_changes(rendering_change_count));
294 }
295 return Ok(result.into());
296 }
297 PublishOutcome::Rejected { rejection } => rejection,
298 };
299 match rejection {
300 PublishRejection::NeedsConfirmation { risky_entities } => {
301 let reason_count: usize = risky_entities.iter().map(|e| e.risk_reasons.len()).sum();
302 let mut fallback = ToolResult::success(json!({
303 "changeset": changeset.id,
304 "published": false,
305 "reason": "needs_teacher_review",
306 "risky_entities": explained(risky_entities),
307 "review_url": review_url,
308 }))
309 .with_hint(hints::needs_teacher_review(&review_url, reason_count));
310 if rendering_change_count > 0 {
311 fallback = fallback.with_hint(hints::rendering_changes(
312 rendering_change_count,
313 ctx.client.client_kind(),
314 ));
315 }
316 if let Some(retry) = &ctx.retry {
317 let mut result = fallback;
320 result
321 .structured
322 .insert("confirmation".into(), json!(retry.confirmation_action()));
323 return Ok(result.into());
324 }
325 Ok(ToolOutcome::NeedsConfirmation(ConfirmationRequest {
326 message: format!(
327 "{reason_count} staged change(s) could affect students. Review them and publish or discard the changeset on this page."
328 ),
329 url: review_url,
330 elicitation_id: changeset.id.to_string(),
331 fallback,
332 }))
333 }
334 PublishRejection::Conflicts { conflicts } => Err(ToolError::refused(
335 ToolErrorCode::PublishConflict,
336 "Nothing was published: these staged pages or entities changed live after they were staged. Drop them with discard_changeset and entities set to their ids, read their published state again, redo your edits to them on top of it, then publish.",
337 json!({"conflicts": conflicts}),
338 )),
339 PublishRejection::ChangedSinceReview(ChangedSinceReview { revision }) => {
340 Err(ToolError::refused(
341 ToolErrorCode::ChangesetChanged,
342 format!(
343 "Nothing was published: the changeset is at revision {revision}, not the one you gave, so it holds changes you haven't seen, maybe from another session. list_changesets shows what it holds now; publish only if the teacher asked for all of it."
344 ),
345 json!({"revision": revision}),
346 ))
347 }
348 PublishRejection::GlobalAdminOnlyExerciseServices { service_names } => {
349 Err(ToolError::refused(
350 ToolErrorCode::AdminOnlyExerciseServices,
351 "Nothing was published: the staged pages add exercises of services only administrators may add.",
352 json!({"services": service_names}),
353 ))
354 }
355 PublishRejection::Blockers { blockers } => Err(ToolError::refused(
356 ToolErrorCode::PublishFailed,
357 "Nothing was published: with the staged changes the course would break rules publishing relies on. Fix the staged entities named here with the patch tools, or drop them with discard_changeset, then publish again.",
358 json!({"blockers": explain_blockers(blockers)}),
359 )),
360 PublishRejection::NotOpen { status } => {
361 Ok(not_open_result(&view.course, changeset.id, status).into())
362 }
363 PublishRejection::SaveFailed { entity, message } => Err(ToolError::refused(
364 ToolErrorCode::PublishFailed,
365 format!(
366 "Nothing was published: saving the staged changes failed: {message}. Fix the staged entities with the patch tools, or drop them with discard_changeset, then publish again."
367 ),
368 json!({"entity": entity}),
369 )),
370 }
371 }
372}
373
374#[async_trait(?Send)]
375impl ToolHandler<McpState> for DiscardChangeset {
376 type Args = DiscardChangesetArgs;
377
378 async fn call(
379 &self,
380 state: &McpState,
381 ctx: &RequestContext,
382 args: DiscardChangesetArgs,
383 ) -> Result<ToolOutcome, ToolError> {
384 let mut conn = connection(state).await?;
385 let (course, changeset) =
386 own_changeset(&mut conn, ctx, args.course.id(), args.changeset.id()).await?;
387 if !changeset.is_open() {
388 return Ok(not_open_result(&course, changeset.id, changeset.status).into());
389 }
390 let Some(entity_ids) = &args.entities else {
391 return match discard(&mut conn, changeset.id, args.revision).await? {
392 DiscardOutcome::Discarded { changeset } => Ok(ToolResult::success(json!({
393 "changeset": changeset.id,
394 "status": changeset.status,
395 "message": "Every staged change in the changeset was dropped; the published course is unchanged.",
396 }))
397 .into()),
398 DiscardOutcome::Rejected { rejection } => {
399 discard_rejected(&course, changeset.id, rejection)
400 }
401 };
402 };
403 discard_some(
404 &mut conn,
405 ctx,
406 course,
407 &changeset,
408 entity_ids,
409 args.revision,
410 )
411 .await
412 }
413}
414
415fn discard_rejected(
416 course: &Course,
417 changeset_id: Uuid,
418 rejection: DiscardRejection,
419) -> Result<ToolOutcome, ToolError> {
420 match rejection {
421 DiscardRejection::NotOpen { status } => {
422 Ok(not_open_result(course, changeset_id, status).into())
423 }
424 DiscardRejection::ChangedSinceReview(ChangedSinceReview { revision }) => {
425 Err(ToolError::refused(
426 ToolErrorCode::ChangesetChanged,
427 format!(
428 "Nothing was dropped: the changeset is at revision {revision}, not the one you gave, so it holds changes you haven't seen. list_changesets shows what it holds now."
429 ),
430 json!({"revision": revision}),
431 ))
432 }
433 DiscardRejection::Blockers { blockers } => Err(ToolError::refused(
434 ToolErrorCode::DiscardConflict,
435 "Nothing was dropped: without these entities the rest of the changeset couldn't be published. Include the entities those changes depend on, or discard the whole changeset.",
436 json!({"blockers": explain_blockers(blockers)}),
437 )),
438 }
439}
440
441async fn discard_some(
443 conn: &mut PgConnection,
444 ctx: &RequestContext,
445 course: Course,
446 changeset: &AgentChangeset,
447 entity_ids: &[IdArgument],
448 revision: Option<i32>,
449) -> Result<ToolOutcome, ToolError> {
450 if entity_ids.is_empty() {
451 return Err(ToolError::InvalidArguments {
452 message: "entities is empty; leave it out to discard the whole changeset".into(),
453 });
454 }
455 let ids: Vec<Uuid> = entity_ids.iter().map(|id| id.id()).collect();
456 let view = load_view(conn, ctx.principal.actor.user_id, course, CourseParts::All).await?;
457 let mut keys = Vec::with_capacity(ids.len());
458 let mut unknown = Vec::new();
459 for id in ids {
460 match view.staged_entities.keys().find(|key| key.entity_id == id) {
461 Some(key) => keys.push(*key),
462 None => unknown.push(id),
463 }
464 }
465 if !unknown.is_empty() {
466 return Err(ToolError::NotFound {
467 message: format!(
468 "the changeset has nothing staged for {unknown:?}; list_changesets lists its entities"
469 ),
470 });
471 }
472 let discarded = match discard_entities(
473 conn,
474 changeset.id,
475 &keys,
476 revision,
477 &RiskBaseline::View(&view),
478 )
479 .await?
480 {
481 Ok(discarded) => discarded,
482 Err(rejection) => return discard_rejected(&view.course, changeset.id, rejection),
483 };
484 Ok(ToolResult::success(json!({
485 "changeset": changeset.id,
486 "status": AgentChangesetStatus::Open,
487 "discarded": discarded,
488 "message": "These entities were dropped from the changeset and show their published state again; the rest stays staged.",
489 }))
490 .with_hint(hints::staged(changeset.id, view.course.is_draft))
491 .into())
492}
493
494async fn live_page(
496 conn: &mut PgConnection,
497 course: &Course,
498 page_id: Uuid,
499) -> Result<Option<Page>, ToolError> {
500 Ok(pages::get_page(conn, page_id)
501 .await
502 .optional()?
503 .filter(|page| {
504 page.course_id == Some(course.id) && page.deleted_at.is_none() && page.exam_id.is_none()
505 }))
506}
507
508#[derive(serde::Serialize)]
510struct PreviewLink {
511 page: Uuid,
512 url: String,
513}
514
515#[async_trait(?Send)]
516impl ToolHandler<McpState> for GetPreviewLink {
517 type Args = GetPreviewLinkArgs;
518
519 async fn call(
520 &self,
521 state: &McpState,
522 ctx: &RequestContext,
523 args: GetPreviewLinkArgs,
524 ) -> Result<ToolOutcome, ToolError> {
525 let page_ids: Vec<Uuid> = match (&args.page, &args.pages) {
526 (Some(page), None) => vec![page.id()],
527 (None, Some(pages)) if !pages.is_empty() => {
528 pages.iter().map(|page| page.id()).collect()
529 }
530 (None, None) => Vec::new(),
531 _ => {
532 return Err(ToolError::InvalidArguments {
533 message:
534 "give page, a non-empty pages list, or neither for the whole changeset"
535 .into(),
536 });
537 }
538 };
539 let course_id = args.course.id();
540 let user_id = ctx.principal.actor.user_id;
541 let mut conn = connection(state).await?;
542 let course = authorize_course(&mut conn, user_id, course_id).await?;
543 let not_found = |page_id: Uuid| ToolError::NotFound {
544 message: format!(
545 "course {course_id} has no page {page_id}; get_course_structure lists its pages"
546 ),
547 };
548 let mut links = Vec::with_capacity(page_ids.len());
549 let Some(changeset) = agent_changesets::get_open(&mut conn, user_id, course.id).await?
550 else {
551 if page_ids.is_empty() {
552 let preview = mint_preview_token(&mut conn, course.id, None, None, user_id).await?;
553 return Ok(ToolResult::success(json!({
554 "is_staged": false,
555 "expires_at": preview.expires_at,
556 "url": state.app_conf.public_url(&preview_path(&preview.token)),
557 "message": "You have nothing staged in this course, so this link shows the published pages.",
558 }))
559 .into());
560 }
561 let mut expires_at = None;
562 for page_id in page_ids {
563 live_page(&mut conn, &course, page_id)
564 .await?
565 .ok_or_else(|| not_found(page_id))?;
566 let preview =
567 mint_preview_token(&mut conn, course.id, None, Some(page_id), user_id).await?;
568 expires_at = Some(preview.expires_at);
569 links.push(PreviewLink {
570 page: page_id,
571 url: state.app_conf.public_url(&preview_path(&preview.token)),
572 });
573 }
574 let mut result = json!({
575 "is_staged": false,
576 "expires_at": expires_at,
577 "message": "You have nothing staged in this course, so these links show the published pages.",
578 });
579 insert_links(&mut result, args.page.is_some(), links);
580 return Ok(ToolResult::success(result).into());
581 };
582 let mut expires_at = None;
583 if page_ids.is_empty() {
584 let changed_pages = changed_page_links(&mut conn, &changeset).await?;
585 if changed_pages.is_empty() {
586 return Ok(ToolResult::success(json!({
587 "is_staged": true,
588 "changeset": changeset.id,
589 "message": "The changeset adds or changes no pages, so there is nothing to preview.",
590 }))
591 .into());
592 }
593 let preview =
594 mint_preview_token(&mut conn, course.id, Some(changeset.id), None, user_id).await?;
595 return Ok(ToolResult::success(json!({
596 "is_staged": true,
597 "changeset": changeset.id,
598 "expires_at": preview.expires_at,
599 "url": state.app_conf.public_url(&preview_path(&preview.token)),
600 "pages": changed_pages.len(),
601 }))
602 .into());
603 }
604 for page_id in page_ids {
605 let key = EntityKey::new(ContentEntityType::Page, page_id);
606 match agent_changeset_entities::get_operation(&mut conn, changeset.id, key).await? {
607 Some(AgentChangesetEntityOperation::Delete) => {
608 return Err(deleted_in_staged_changes(page_id));
609 }
610 Some(_) => {}
611 None => {
612 live_page(&mut conn, &course, page_id)
613 .await?
614 .ok_or_else(|| not_found(page_id))?;
615 }
616 }
617 let preview = mint_preview_token(
618 &mut conn,
619 course.id,
620 Some(changeset.id),
621 Some(page_id),
622 user_id,
623 )
624 .await?;
625 expires_at = Some(preview.expires_at);
626 links.push(PreviewLink {
627 page: page_id,
628 url: state.app_conf.public_url(&preview_path(&preview.token)),
629 });
630 }
631 let mut result = json!({
632 "is_staged": true,
633 "changeset": changeset.id,
634 "expires_at": expires_at,
635 });
636 insert_links(&mut result, args.page.is_some(), links);
637 Ok(ToolResult::success(result).into())
638 }
639}
640
641fn insert_links(result: &mut Value, is_single_page: bool, links: Vec<PreviewLink>) {
644 let (field, value) = match (is_single_page, links.as_slice()) {
645 (true, [link]) => ("url", json!(link.url)),
646 _ => ("previews", json!(links)),
647 };
648 if let Value::Object(fields) = result {
649 fields.insert(field.into(), value);
650 }
651}