1use std::cell::OnceCell;
4use std::collections::{HashMap, HashSet};
5
6use async_trait::async_trait;
7use headless_lms_authorization::fetch_user_roles;
8use headless_lms_models::{
9 courses,
10 library::entity_snapshots::{ContentEntityType, EntityKey},
11 organizations, peer_or_self_review_configs,
12};
13use serde::Serialize;
14use serde_json::{Value, json};
15use uuid::Uuid;
16
17use crate::access::{
18 connection, editable_courses, load_page_facts, open_course, open_course_parts, page_in_course,
19 state_of,
20};
21use crate::diff::{self, ChangeKind};
22use crate::engine::PageType;
23use crate::engine::read::{
24 ReadOptions, ReadResult, ReadState, Selector, read_page, read_structure,
25};
26use crate::hints;
27use crate::page_readiness;
28use crate::snapshots;
29use crate::state::McpState;
30use crate::tools::args::{CourseArgs, GetCourseStructureArgs, GetPageContentArgs, ListCoursesArgs};
31use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome, ToolResult};
32use headless_lms_models::library::agent_changesets::course_state::{CourseParts, CourseView};
33
34pub struct ListCourses;
36pub struct GetCourseStructure;
38pub struct GetPageContent;
40pub struct GetCourseSettings;
42pub struct GetGlossaryAndReferences;
44
45const STRUCTURE_ENTITIES: [ContentEntityType; 3] = [
46 ContentEntityType::Module,
47 ContentEntityType::Chapter,
48 ContentEntityType::Page,
49];
50const SETTINGS_ENTITIES: [ContentEntityType; 2] =
52 [ContentEntityType::CourseSettings, ContentEntityType::Module];
53const GLOSSARY_ENTITIES: [ContentEntityType; 2] = [
54 ContentEntityType::GlossaryTerm,
55 ContentEntityType::Reference,
56];
57
58fn staged_keys<'a>(
61 view: &'a CourseView,
62 entity_types: &'a [ContentEntityType],
63) -> impl Iterator<Item = EntityKey> + 'a {
64 view.staged_entities
65 .keys()
66 .copied()
67 .filter(|key| entity_types.contains(&key.entity_type))
68}
69
70pub(crate) fn page_pending(view: &CourseView, page_id: Uuid) -> HashMap<Uuid, ChangeKind> {
72 diff::diff_pages(&view.live, &view.staged, &[page_id])
73 .into_iter()
74 .filter(|b| b.kind != ChangeKind::Removed)
75 .map(|b| (b.block_id, b.kind))
76 .collect()
77}
78
79fn read_options<'a>(
82 ctx: &RequestContext,
83 selector_count: usize,
84 read_state: ReadState,
85 pending: &'a HashMap<Uuid, ChangeKind>,
86) -> ReadOptions<'a> {
87 ReadOptions {
88 budget_bytes: ctx.client.result_budget_bytes() / selector_count.max(1),
89 pending: match read_state {
90 ReadState::Staged => Some(pending),
91 ReadState::Published => None,
92 },
93 }
94}
95
96fn selectors_or_outline(selectors: Option<Vec<Selector>>) -> Vec<Selector> {
97 match selectors {
98 Some(selectors) if !selectors.is_empty() => selectors,
99 _ => vec![Selector::default()],
100 }
101}
102
103#[derive(Serialize)]
105struct PageInfo<'a> {
106 id: Uuid,
107 title: &'a str,
108 url_path: &'a str,
109 hidden: bool,
110 page_type: PageType,
111 has_staged_changes: bool,
112}
113
114fn missing_page(page_id: Uuid, read_state: ReadState) -> ToolError {
115 ToolError::NotFound {
116 message: match read_state {
117 ReadState::Staged => format!(
118 "page {page_id} is deleted in your staged changes; state: published reads the live page"
119 ),
120 ReadState::Published => {
121 format!("page {page_id} isn't published yet; it exists only in your staged changes")
122 }
123 },
124 }
125}
126
127#[async_trait(?Send)]
128impl ToolHandler<McpState> for ListCourses {
129 type Args = ListCoursesArgs;
130
131 async fn call(
132 &self,
133 state: &McpState,
134 ctx: &RequestContext,
135 _: ListCoursesArgs,
136 ) -> Result<ToolOutcome, ToolError> {
137 let mut conn = connection(state).await?;
138 let user_id = ctx.principal.actor.user_id;
139 let roles = fetch_user_roles(&mut conn, Some(user_id)).await?;
140 let courses = editable_courses(
141 &roles,
142 courses::get_by_course_or_organization_role(&mut conn, user_id).await?,
143 );
144 let organization_ids: HashSet<Uuid> = courses.iter().map(|c| c.organization_id).collect();
145 let organization_ids: Vec<Uuid> = organization_ids.into_iter().collect();
146 let organization_names: HashMap<Uuid, String> =
147 organizations::get_by_ids(&mut conn, &organization_ids)
148 .await?
149 .into_iter()
150 .map(|organization| (organization.id, organization.name))
151 .collect();
152 let group_ids: HashSet<Uuid> = courses.iter().map(|c| c.course_language_group_id).collect();
153 let group_ids: Vec<Uuid> = group_ids.into_iter().collect();
154 let mut versions_by_group: HashMap<Uuid, Vec<courses::Course>> = HashMap::new();
155 for version in courses::get_by_course_language_group_ids(&mut conn, &group_ids).await? {
156 versions_by_group
157 .entry(version.course_language_group_id)
158 .or_default()
159 .push(version);
160 }
161 let listed: Vec<Value> = courses
162 .iter()
163 .map(|course| {
164 let language_versions: Vec<Value> = versions_by_group
165 .get(&course.course_language_group_id)
166 .into_iter()
167 .flatten()
168 .filter(|version| version.id != course.id)
169 .map(
170 |version| json!({"id": version.id, "language_code": version.language_code}),
171 )
172 .collect();
173 json!({
174 "id": course.id,
175 "name": course.name,
176 "slug": course.slug,
177 "language_code": course.language_code,
178 "is_draft": course.is_draft,
179 "is_test_mode": course.is_test_mode,
180 "organization": {
181 "id": course.organization_id,
182 "name": organization_names.get(&course.organization_id),
183 },
184 "url": state.app_conf.public_url(&format!("/manage/courses/{}", course.id)),
185 "copied_from_id": course.copied_from,
186 "language_group_id": course.course_language_group_id,
187 "language_versions": language_versions,
188 })
189 })
190 .collect();
191 let mut result = json!({ "courses": listed });
192 if roles.iter().any(|r| r.is_global) {
193 result["note"] = json!(
194 "Your account has a global role, which allows editing every course; only courses where you hold a course or organization role are listed. Any other course works by its id."
195 );
196 }
197 Ok(ToolResult::success(result).into())
198 }
199}
200
201#[async_trait(?Send)]
202impl ToolHandler<McpState> for GetCourseStructure {
203 type Args = GetCourseStructureArgs;
204
205 async fn call(
206 &self,
207 state: &McpState,
208 ctx: &RequestContext,
209 args: GetCourseStructureArgs,
210 ) -> Result<ToolOutcome, ToolError> {
211 let view = open_course(state, ctx, args.course.id()).await?;
212 let facts = load_page_facts(state, view.course.id).await?;
213 let selectors = selectors_or_outline(args.selectors);
214 let pending = diff::structure_changes(&view.live, &view.staged);
215 let (live, staged) = (OnceCell::new(), OnceCell::new());
217 let mut reads: Vec<ReadResult> = Vec::with_capacity(selectors.len());
218 for selector in &selectors {
219 let read_state = selector.state.unwrap_or_default();
220 let structure = match read_state {
221 ReadState::Staged => {
222 staged.get_or_init(|| snapshots::structure(&view.staged, &facts))
223 }
224 ReadState::Published => {
225 live.get_or_init(|| snapshots::structure(&view.live, &facts))
226 }
227 };
228 let options = read_options(ctx, selectors.len(), read_state, &pending);
229 reads.push(read_structure(structure, selector, &options)?);
230 }
231 Ok(ToolResult::success(json!({
232 "course": {"id": view.course.id, "name": view.staged.settings.name, "slug": view.course.slug},
233 "has_staged_changes": staged_keys(&view, &STRUCTURE_ENTITIES).next().is_some(),
234 "reads": reads,
235 }))
236 .into())
237 }
238}
239
240#[async_trait(?Send)]
241impl ToolHandler<McpState> for GetPageContent {
242 type Args = GetPageContentArgs;
243
244 async fn call(
245 &self,
246 state: &McpState,
247 ctx: &RequestContext,
248 args: GetPageContentArgs,
249 ) -> Result<ToolOutcome, ToolError> {
250 let view = open_course(state, ctx, args.course.id()).await?;
251 let page_id = page_in_course(&view, args.page.id())?;
252 let selectors = selectors_or_outline(args.selectors);
253 let pending = page_pending(&view, page_id);
254 let page_in = |read_state| {
255 state_of(&view, read_state)
256 .page(page_id)
257 .ok_or_else(|| missing_page(page_id, read_state))
258 };
259 let first_state = selectors
260 .first()
261 .and_then(|selector| selector.state)
262 .unwrap_or_default();
263 let page = page_in(first_state)?;
264 let page_info = PageInfo {
265 id: page.id,
266 title: &page.title,
267 url_path: &page.url_path,
268 hidden: page.hidden,
269 page_type: snapshots::page_type(page),
270 has_staged_changes: view
271 .staged_row(EntityKey::new(ContentEntityType::Page, page_id))
272 .is_some(),
273 };
274 let (live, staged) = (OnceCell::new(), OnceCell::new());
275 let mut reads = Vec::with_capacity(selectors.len());
276 for selector in &selectors {
277 let read_state = selector.state.unwrap_or_default();
278 let page = page_in(read_state)?;
279 let document = match read_state {
280 ReadState::Staged => &staged,
281 ReadState::Published => &live,
282 }
283 .get_or_init(|| snapshots::page_ref(page).to_document());
284 let options = read_options(ctx, selectors.len(), read_state, &pending);
285 reads.push(read_page(document, selector, &options)?);
286 }
287 let mut result = ToolResult::success(json!({"page": page_info, "reads": reads}));
288 if let Some(staged_page) = view.staged.page(page_id) {
289 let placeholders = page_readiness::placeholder_texts(&staged_page.content);
290 if !placeholders.is_empty() {
291 result = result.with_hint(hints::template_placeholders(page_id, &placeholders));
292 }
293 }
294 Ok(result.into())
295 }
296}
297
298#[async_trait(?Send)]
299impl ToolHandler<McpState> for GetCourseSettings {
300 type Args = CourseArgs;
301
302 async fn call(
303 &self,
304 state: &McpState,
305 ctx: &RequestContext,
306 args: CourseArgs,
307 ) -> Result<ToolOutcome, ToolError> {
308 let view = open_course_parts(state, ctx, args.course.id(), CourseParts::Settings).await?;
309 let default_peer_review =
310 peer_or_self_review_configs::get_course_default_cms_peer_review_configuration(
311 &mut *connection(state).await?,
312 view.course.id,
313 )
314 .await?;
315 let default_peer_review = default_peer_review.map(|mut default| {
316 default
317 .peer_or_self_review_questions
318 .sort_by_key(|question| question.order_number);
319 let config = &default.peer_or_self_review_config;
320 json!({
321 "peer_reviews_to_give": config.peer_reviews_to_give,
322 "peer_reviews_to_receive": config.peer_reviews_to_receive,
323 "accepting_threshold": config.accepting_threshold,
324 "processing_strategy": config.processing_strategy,
325 "points_are_all_or_nothing": config.points_are_all_or_nothing,
326 "reset_answer_if_zero_points_from_review": config.reset_answer_if_zero_points_from_review,
327 "questions": default.peer_or_self_review_questions.iter().map(|question| json!({
328 "question": question.question,
329 "question_type": question.question_type,
330 "answer_required": question.answer_required,
331 "weight": question.weight,
332 })).collect::<Vec<_>>(),
333 })
334 });
335 Ok(ToolResult::success(json!({
336 "settings": snapshots::settings_document(&view.staged, &view.course),
337 "default_peer_review": default_peer_review,
338 "has_staged_changes": staged_keys(&view, &SETTINGS_ENTITIES).next().is_some(),
339 "read_only_fields": ["slug", "language_code", "is_draft", "is_test_mode", "closed_at"],
340 }))
341 .into())
342 }
343}
344
345#[async_trait(?Send)]
346impl ToolHandler<McpState> for GetGlossaryAndReferences {
347 type Args = CourseArgs;
348
349 async fn call(
350 &self,
351 state: &McpState,
352 ctx: &RequestContext,
353 args: CourseArgs,
354 ) -> Result<ToolOutcome, ToolError> {
355 let view = open_course_parts(state, ctx, args.course.id(), CourseParts::Glossary).await?;
356 let staged_ids: Vec<Uuid> = staged_keys(&view, &GLOSSARY_ENTITIES)
357 .map(|key| key.entity_id)
358 .collect();
359 let mut result =
360 serde_json::to_value(snapshots::glossary_document(&view.staged)).unwrap_or_default();
361 result["staged_changes"] = json!(staged_ids);
362 Ok(ToolResult::success(result).into())
363 }
364}