Skip to main content

headless_lms_mcp/tools/
args.rs

1//! Tool arguments. Their JSON Schemas are the tools' input schemas; keep field docs short, every
2//! schema must stay well under the clients' size limits.
3
4use std::borrow::Cow;
5
6use schemars::{JsonSchema, Schema, SchemaGenerator};
7use serde::de::Error as _;
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::engine::op::PatchOp;
13use crate::engine::read::{ReadState, Selector};
14use crate::engine::structural_replace::{StructuralAction, StructuralMatch};
15use crate::guide::GuideTopic;
16
17/// An id argument: a UUID, also accepted with the leading `/` of a path to it.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
19#[serde(transparent)]
20pub struct IdArgument(Uuid);
21
22impl IdArgument {
23    /// The id, without the leading `/` it may have been sent with.
24    pub fn id(self) -> Uuid {
25        self.0
26    }
27}
28
29/// A UUID's schema, without the type's description in every id field.
30impl JsonSchema for IdArgument {
31    fn schema_name() -> Cow<'static, str> {
32        Uuid::schema_name()
33    }
34
35    fn inline_schema() -> bool {
36        true
37    }
38
39    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
40        Uuid::json_schema(generator)
41    }
42}
43
44impl<'de> Deserialize<'de> for IdArgument {
45    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
46        let raw = String::deserialize(deserializer)?;
47        Uuid::parse_str(raw.trim().trim_start_matches('/'))
48            .map(IdArgument)
49            .map_err(|_| D::Error::custom(format!("expected an id (a UUID), got \"{raw}\"")))
50    }
51}
52
53/// A field that may be null: `Some(None)` for null, `None` (through `default`) when left out.
54fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
55    deserializer: D,
56) -> Result<Option<Option<T>>, D::Error> {
57    Option::<T>::deserialize(deserializer).map(Some)
58}
59
60#[derive(Debug, Deserialize, JsonSchema)]
61#[serde(deny_unknown_fields)]
62pub struct ListCoursesArgs {}
63
64#[derive(Debug, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct GetCourseStructureArgs {
67    /// Course id.
68    #[schemars(extend("x-mcp-header" = "Course"))]
69    pub course: IdArgument,
70    /// What to read; none reads an outline of the whole course.
71    pub selectors: Option<Vec<Selector>>,
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75#[serde(deny_unknown_fields)]
76pub struct GetPageContentArgs {
77    #[schemars(extend("x-mcp-header" = "Course"))]
78    pub course: IdArgument,
79    /// Page id; the page must belong to the course.
80    pub page: IdArgument,
81    /// What to read; none reads an outline of the whole page.
82    pub selectors: Option<Vec<Selector>>,
83}
84
85#[derive(Debug, Deserialize, JsonSchema)]
86#[serde(deny_unknown_fields)]
87pub struct CourseArgs {
88    #[schemars(extend("x-mcp-header" = "Course"))]
89    pub course: IdArgument,
90}
91
92#[derive(Debug, Deserialize, JsonSchema)]
93#[serde(deny_unknown_fields)]
94pub struct PatchPageContentArgs {
95    #[schemars(extend("x-mcp-header" = "Course"))]
96    pub course: IdArgument,
97    pub page: IdArgument,
98    /// Applied in order; all or nothing.
99    #[schemars(with = "Vec<PatchOp>")]
100    pub ops: Vec<Value>,
101    /// Stage nothing; return the diff.
102    #[serde(default)]
103    pub dry_run: bool,
104    /// Read after the ops; $labels allowed.
105    pub read_back: Option<Selector>,
106}
107
108#[derive(Debug, Deserialize, JsonSchema)]
109#[serde(deny_unknown_fields)]
110pub struct GetExerciseArgs {
111    #[schemars(extend("x-mcp-header" = "Course"))]
112    pub course: IdArgument,
113    /// Exercise id, from an exercise block's exercise_id.
114    pub exercise: IdArgument,
115    /// Paths start at /tasks/<task id>, /tasks/<task id>/specs or /review_instructions; none reads an outline of each.
116    pub selectors: Option<Vec<Selector>>,
117}
118
119#[derive(Debug, Deserialize, JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct ListExercisesArgs {
122    #[schemars(extend("x-mcp-header" = "Course"))]
123    pub course: IdArgument,
124    pub scope: Option<ContentScope>,
125    /// staged (default, with your unpublished changes) or published.
126    pub state: Option<ReadState>,
127    pub offset: Option<u32>,
128    /// Default 100, at most 300.
129    pub limit: Option<u32>,
130}
131
132/// Which review settings `patch_exercise` changes.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134#[serde(rename_all = "snake_case")]
135pub enum ReviewConfigChoice {
136    /// Review ops edit the course default, which every exercise without its own uses.
137    EditCourseDefault,
138    /// The exercise gets its own copy of the course default; review ops edit the copy.
139    Fork,
140    /// The exercise drops its own settings and uses the course default.
141    UseCourseDefault,
142}
143
144/// Serialized as the call's audit record, without the course the record has already.
145#[derive(Debug, Serialize, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147pub struct PatchExerciseArgs {
148    #[schemars(extend("x-mcp-header" = "Course"))]
149    #[serde(skip_serializing)]
150    pub course: IdArgument,
151    pub exercise: IdArgument,
152    /// New name, plain text.
153    pub name: Option<String>,
154    /// Own deadline, RFC 3339 with offset; null clears it so the chapter's applies. Prefer chapter deadlines.
155    #[serde(
156        default,
157        deserialize_with = "present",
158        skip_serializing_if = "Option::is_none"
159    )]
160    #[schemars(with = "Option<String>")]
161    pub deadline: Option<Option<String>>,
162    /// Page-content ops; paths start at /tasks/<task id> or /review_instructions.
163    #[schemars(with = "Option<Vec<PatchOp>>")]
164    pub ops: Option<Vec<Value>>,
165    pub review_config: Option<ReviewConfigChoice>,
166    #[serde(default)]
167    pub dry_run: bool,
168}
169
170#[derive(Debug, Deserialize, JsonSchema)]
171#[serde(deny_unknown_fields)]
172pub struct PatchCourseStructureArgs {
173    #[schemars(extend("x-mcp-header" = "Course"))]
174    pub course: IdArgument,
175    #[schemars(with = "Vec<PatchOp>")]
176    pub ops: Vec<Value>,
177    #[serde(default)]
178    pub dry_run: bool,
179    /// With dry_run: also return the full staging diff, not just the outline of what changes.
180    #[serde(default)]
181    pub detail: bool,
182    pub read_back: Option<Selector>,
183}
184
185/// Arguments of `patch_course_settings` and `patch_course_glossary_and_references`.
186#[derive(Debug, Deserialize, JsonSchema)]
187#[serde(deny_unknown_fields)]
188pub struct PatchCourseArgs {
189    #[schemars(extend("x-mcp-header" = "Course"))]
190    pub course: IdArgument,
191    #[schemars(with = "Vec<PatchOp>")]
192    pub ops: Vec<Value>,
193    #[serde(default)]
194    pub dry_run: bool,
195}
196
197/// Part of a course; empty means the whole course.
198#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
199#[serde(deny_unknown_fields)]
200pub struct ContentScope {
201    pub chapter: Option<IdArgument>,
202    pub page: Option<IdArgument>,
203}
204
205/// Serialized as the call's audit record, without the course the record has already.
206#[derive(Debug, Serialize, Deserialize, JsonSchema)]
207#[serde(deny_unknown_fields)]
208pub struct FindReplaceArgs {
209    #[schemars(extend("x-mcp-header" = "Course"))]
210    #[serde(skip_serializing)]
211    pub course: IdArgument,
212    pub scope: Option<ContentScope>,
213    /// Literal text (whole words at word edges) or, with regex, a pattern. Matches the stored HTML.
214    pub find: String,
215    /// Leave out to search only. With regex, $1-style groups; Rust regex syntax has no backreferences or lookaround, and (?i) makes it case-insensitive.
216    pub replace: Option<String>,
217    #[serde(default)]
218    pub regex: bool,
219    /// Attribute names, or /regex/ on names; default: text attributes.
220    pub attributes: Option<Vec<String>>,
221    /// Fail unless exactly this many matches.
222    pub expected_count: Option<u32>,
223    /// Also search exercises' task and review instructions. Default true.
224    pub include_exercise_instructions: Option<bool>,
225    /// Also search exercise names. Default false.
226    pub include_exercise_names: Option<bool>,
227    #[serde(default)]
228    pub dry_run: bool,
229}
230
231/// Serialized as the call's audit record, without the course the record has already.
232#[derive(Debug, Serialize, Deserialize, JsonSchema)]
233#[serde(deny_unknown_fields)]
234pub struct StructuralReplaceArgs {
235    #[schemars(extend("x-mcp-header" = "Course"))]
236    #[serde(skip_serializing)]
237    pub course: IdArgument,
238    pub scope: Option<ContentScope>,
239    #[serde(rename = "match")]
240    pub pattern: StructuralMatch,
241    pub action: StructuralAction,
242    pub expected_count: Option<u32>,
243    /// Also match blocks in exercises' task and review instructions. Default true.
244    pub include_exercise_instructions: Option<bool>,
245    #[serde(default)]
246    pub dry_run: bool,
247}
248
249#[derive(Debug, Deserialize, JsonSchema)]
250#[serde(deny_unknown_fields)]
251pub struct UploadFileArgs {
252    #[schemars(extend("x-mcp-header" = "Course"))]
253    pub course: IdArgument,
254    /// File name with extension, e.g. diagram.png.
255    pub file_name: String,
256    /// The file's bytes, base64-encoded.
257    pub content_base64: String,
258}
259
260#[derive(Debug, Deserialize, JsonSchema)]
261#[serde(deny_unknown_fields)]
262pub struct ListChangesetsArgs {
263    /// Limit to one course.
264    #[schemars(extend("x-mcp-header" = "Course"))]
265    pub course: Option<IdArgument>,
266}
267
268#[derive(Debug, Deserialize, JsonSchema)]
269#[serde(deny_unknown_fields)]
270pub struct PublishChangesetArgs {
271    #[schemars(extend("x-mcp-header" = "Course"))]
272    pub course: IdArgument,
273    /// Changeset id from list_changesets or a write result.
274    pub changeset: IdArgument,
275    /// The changeset revision you last saw (changeset_revision of your last write result, or list_changesets); if it changed since, nothing is published.
276    pub revision: i32,
277}
278
279#[derive(Debug, Deserialize, JsonSchema)]
280#[serde(deny_unknown_fields)]
281pub struct DiscardChangesetArgs {
282    #[schemars(extend("x-mcp-header" = "Course"))]
283    pub course: IdArgument,
284    /// Changeset id from list_changesets or a write result.
285    pub changeset: IdArgument,
286    /// Entity ids to drop, e.g. those a publish_conflict lists; the rest stays staged. Omit to drop everything.
287    pub entities: Option<Vec<IdArgument>>,
288    /// The changeset revision you last saw (changeset_revision of a write result, or list_changesets); if it changed since, nothing is dropped.
289    pub revision: Option<i32>,
290}
291
292#[derive(Debug, Deserialize, JsonSchema)]
293#[serde(deny_unknown_fields)]
294pub struct GetPreviewLinkArgs {
295    #[schemars(extend("x-mcp-header" = "Course"))]
296    pub course: IdArgument,
297    /// One page; its link comes back as `url`. Give this or `pages`, or neither for one link to the whole changeset (all pages of the course when nothing is staged).
298    pub page: Option<IdArgument>,
299    /// Several pages; their links come back in `previews`, one per page. Give this or `page`, or neither for one link to the whole changeset (all pages of the course when nothing is staged).
300    pub pages: Option<Vec<IdArgument>>,
301}
302
303#[derive(Debug, Deserialize, JsonSchema)]
304#[serde(deny_unknown_fields)]
305pub struct GetHistoryArgs {
306    #[schemars(extend("x-mcp-header" = "Course"))]
307    pub course: IdArgument,
308    /// A page, chapter, module, term or reference id; none lists the whole course.
309    pub entity: Option<IdArgument>,
310    pub limit: Option<u32>,
311}
312
313#[derive(Debug, Deserialize, JsonSchema)]
314#[serde(deny_unknown_fields)]
315pub struct RevertChangeArgs {
316    #[schemars(extend("x-mcp-header" = "Course"))]
317    pub course: IdArgument,
318    /// The write call's id from get_history.
319    pub call: IdArgument,
320    #[serde(default)]
321    pub dry_run: bool,
322}
323
324#[derive(Debug, Deserialize, JsonSchema)]
325#[serde(deny_unknown_fields)]
326pub struct ReadPublicCourseArgs {
327    /// A courses.mooc.fi course or page URL, /org/<organization>/courses/<course>/<page path>. Or give course_slug.
328    pub url: Option<String>,
329    /// The course's slug, instead of url.
330    pub course_slug: Option<String>,
331    /// Page path in the course, e.g. /chapter-1/loops; / is the front page. Overrides the URL's page. None reads the outline.
332    pub path: Option<String>,
333    /// Where to continue a long page or outline; next_offset of the previous result.
334    pub offset: Option<u32>,
335}
336
337#[derive(Debug, Deserialize, JsonSchema)]
338#[serde(deny_unknown_fields)]
339pub struct GuideArgs {
340    /// e.g. ["core/paragraph", "moocfi/infobox"].
341    pub block_types: Option<Vec<String>>,
342    pub topics: Option<Vec<GuideTopic>>,
343}