Skip to main content

headless_lms_mcp/handlers/
reads.rs

1//! The read-only tools: course listing, structure, page content, settings, glossary.
2
3use 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
34/// Handler of the `list_courses` tool.
35pub struct ListCourses;
36/// Handler of the `get_course_structure` tool.
37pub struct GetCourseStructure;
38/// Handler of the `get_page_content` tool.
39pub struct GetPageContent;
40/// Handler of the `get_course_settings` tool.
41pub struct GetCourseSettings;
42/// Handler of the `get_course_glossary_and_references` tool.
43pub struct GetGlossaryAndReferences;
44
45const STRUCTURE_ENTITIES: [ContentEntityType; 3] = [
46    ContentEntityType::Module,
47    ContentEntityType::Chapter,
48    ContentEntityType::Page,
49];
50/// Module rows count because the settings document holds each module's settings.
51const SETTINGS_ENTITIES: [ContentEntityType; 2] =
52    [ContentEntityType::CourseSettings, ContentEntityType::Module];
53const GLOSSARY_ENTITIES: [ContentEntityType; 2] = [
54    ContentEntityType::GlossaryTerm,
55    ContentEntityType::Reference,
56];
57
58/// The entities of `entity_types` that the user's changeset stages, deleted ones included. Every
59/// read tool's `has_staged_changes` comes from these rows.
60fn 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
70/// Blocks of the page with staged changes, for marking them in staged reads.
71pub(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
79/// Splits the client's result budget evenly between the selectors. `pending` marks only staged
80/// reads.
81fn 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/// The page facts `get_page_content` reports with its reads.
104#[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        // Built only for the states the selectors read: each build walks every block.
216        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}