1use std::sync::Arc;
5
6use rmcp::model::{MetaObject, ToolAnnotations};
7use serde_json::{Map, Value, json};
8
9use super::schema::input_schema;
10use super::{McpScope, Tool, ToolHandler};
11use crate::engine::op::PatchTarget;
12use crate::hints::SKILL_DECLINED;
13
14#[derive(Debug, Clone)]
16pub struct ToolDefinition {
17 pub name: &'static str,
18 pub scope: McpScope,
20 pub requires_skill: bool,
22 pub header_params: Vec<(String, String)>,
24 pub listing: rmcp::model::Tool,
26}
27
28impl<S> Tool<S> {
29 fn usable_without_skill(mut self) -> Self {
31 self.definition.requires_skill = false;
32 self
33 }
34
35 fn always_loaded(mut self) -> Self {
37 self.definition
38 .listing
39 .meta
40 .get_or_insert_with(MetaObject::default)
41 .insert("anthropic/alwaysLoad".into(), json!(true));
42 self
43 }
44
45 fn taking_ops(mut self, target: PatchTarget) -> Self {
47 let schema = Arc::make_mut(&mut self.definition.listing.input_schema);
48 if let Some(op) = schema
49 .get_mut("properties")
50 .and_then(|p| p.pointer_mut("/ops/items/properties/op"))
51 {
52 op["enum"] = json!(target.allowed_ops());
53 }
54 self
55 }
56}
57
58fn read(title: &'static str) -> ToolAnnotations {
59 ToolAnnotations::with_title(title)
60 .read_only(true)
61 .destructive(false)
62 .idempotent(true)
63 .open_world(false)
64}
65
66fn write(title: &'static str, is_destructive: bool) -> ToolAnnotations {
67 ToolAnnotations::with_title(title)
68 .read_only(false)
69 .destructive(is_destructive)
70 .idempotent(false)
71 .open_world(false)
72}
73
74macro_rules! staging_note {
76 () => {
77 " Every write stages into your open changeset for the course; nothing is live until publish_changeset or the teacher publishes it. Ops apply in order, all or nothing; invariants are checked at the end. dry_run: true stages nothing and returns the diff."
78 };
79}
80
81fn define<S, H: ToolHandler<S> + 'static>(
82 handler: H,
83 name: &'static str,
84 description: &'static str,
85 annotations: ToolAnnotations,
86 scope: McpScope,
87) -> Tool<S> {
88 let mut input_schema = input_schema::<H::Args>();
89 if let Value::Object(properties) = input_schema
91 .entry("properties")
92 .or_insert_with(|| json!({}))
93 {
94 properties.insert(
95 "skill_version".into(),
96 json!({"type": "string", "description": format!("Version from the course-material skill's VERSION file, or \"{SKILL_DECLINED}\" once the teacher has said no to installing the skill. Without either, calls are refused.")}),
97 );
98 }
99 let header_params = header_params(&input_schema);
100 let title = annotations.title.clone().unwrap_or_default();
101 let mut listing =
102 rmcp::model::Tool::new(name, description, Arc::new(input_schema)).with_title(title);
103 if annotations.read_only_hint == Some(true) {
104 listing = listing.with_meta(MetaObject::from(Map::from_iter([(
105 "anthropic/maxResultSizeChars".to_string(),
106 json!(100_000),
107 )])));
108 }
109 Tool {
110 definition: ToolDefinition {
111 name,
112 scope,
113 requires_skill: true,
114 header_params,
115 listing: listing.with_annotations(annotations),
116 },
117 handler: Box::new(handler),
118 }
119}
120
121fn header_params(input_schema: &Map<String, Value>) -> Vec<(String, String)> {
122 input_schema
123 .get("properties")
124 .and_then(Value::as_object)
125 .map(|properties| {
126 properties
127 .iter()
128 .filter_map(|(name, schema)| {
129 Some((
130 name.clone(),
131 schema.get("x-mcp-header")?.as_str()?.to_string(),
132 ))
133 })
134 .collect()
135 })
136 .unwrap_or_default()
137}
138
139pub fn list_courses<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
141 define(
142 handler,
143 "list_courses",
144 "Lists the courses you can edit: id, name, slug, language, draft and test-mode state, organization, copied_from_id (the course it was copied from), language_group_id and language_versions (the group's other courses, id and language only; those you can edit are rows here too). Start here to find a course id.",
145 read("List courses"),
146 McpScope::MaterialRead,
147 )
148 .always_loaded()
149}
150
151pub fn get_course_structure<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
153 define(
154 handler,
155 "get_course_structure",
156 "Reads modules, chapters and pages (titles, paths, chapter dates, hidden state, block and exercise counts, page_language_group_id: shared with the page's versions in the course's other languages, null until a new page is published). Selectors: path (/course, /course/pages, /<module id>/chapters, /<chapter id>, /<page id>/title), through (sibling range), around, offset/limit on lists, mode outline|raw, state staged (default, includes your unpublished changes) or published. Paged results end with has_more and an id-anchored next selector.",
157 read("Read course structure"),
158 McpScope::MaterialRead,
159 )
160 .always_loaded()
161}
162
163pub fn get_page_content<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
165 define(
166 handler,
167 "get_page_content",
168 "Reads a page's blocks. Start with an outline (no selectors): ids, types and short previews. Then read what you'll change with mode raw: path /<block id>, a field /<block id>/attributes/content, a children list /<block id>/innerBlocks, a range path+through, around n siblings, or offset/limit over a list (\"\" is the top level). Every block comes with clientId and a ready path. state: published reads what students see.",
169 read("Read page content"),
170 McpScope::MaterialRead,
171 )
172 .always_loaded()
173}
174
175pub fn get_course_settings<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
177 define(
178 handler,
179 "get_course_settings",
180 "Reads course settings (name, description, listing, chapter locking, AI policy, audiences, module completion requirement) and each module's completion policy and certificates. Publishing and closing switches are shown but read-only. default_peer_review is the course default peer review config (counts, threshold, strategy, questions; read-only) or null; get_exercise reads its instructions.",
181 read("Read course settings"),
182 McpScope::MaterialRead,
183 )
184}
185
186pub fn get_course_glossary_and_references<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
188 define(
189 handler,
190 "get_course_glossary_and_references",
191 "Reads the course glossary (id, term, definition) and references (id, citation_key, bibtex).",
192 read("Read glossary and references"),
193 McpScope::MaterialRead,
194 )
195}
196
197pub fn get_exercise<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
199 define(
200 handler,
201 "get_exercise",
202 "Reads an exercise: name, tasks, own and effective deadline, whether it uses the course default review config or its own, read-only settings, has_submissions (only when students have answered), and an outline of each task's instructions and of the review instructions it shows. Selectors as in get_page_content, with paths starting at /tasks/<task id> or /review_instructions. /tasks/<task id>/specs (each task's specs_path) reads the task's private, public and model solution specs (answer keys included); /specs/<kind>/<JSON pointer> reads part of one when the whole is too large.",
203 read("Read exercise"),
204 McpScope::MaterialRead,
205 )
206}
207
208pub fn list_exercises<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
210 define(
211 handler,
212 "list_exercises",
213 "Lists the exercises of a course, chapter or page (scope) in course order: id, name, page, chapter_id (whose deadline applies without an own one), order on the page, task types, score_maximum, tries, deadline_own, deadline_effective, review_source (own, default or none), has_submissions, copied_from and exercise_language_group_id (pairs language versions and copies). state staged (default) or published; offset/limit page the list. Chapter dates and paths come from get_course_structure, review details from get_exercise and get_course_settings.",
214 read("List exercises"),
215 McpScope::MaterialRead,
216 )
217}
218
219pub fn read_public_course<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
221 define(
222 handler,
223 "read_public_course",
224 "Reads another course on courses.mooc.fi as reference material, published state only, as far as the course site lets you open it (drafts, join-code courses and the pages of unopened chapters are refused unless your roles or a used join code open them; hidden pages aren't read). Takes its course or page URL, or course_slug. Without path: the course's name, organization, language, URL and outline (chapters with status and page titles with paths). With path: that page's Gutenberg blocks and its exercises' names, points, deadlines, and the instructions and public specs of the tasks the course site shows you (an exercise with several slides and none chosen for you shows a random one, so calls can differ); offset continues a long page or outline. Results are reference_only: not a course you work on.",
225 read("Read a public course").idempotent(false),
227 McpScope::MaterialRead,
228 )
229}
230
231pub fn patch_page_content<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
233 define(
234 handler,
235 PatchTarget::PageContent.tool_name(),
236 concat!(
237 "Edits one page's blocks with JSON-Patch-style ops. Paths start at a block id: /<id>, /<id>/innerBlocks, /<id>/attributes/<name>, /<id>/name; \"\" is the top level; numeric indexes are rejected. ",
238 "add (value: block or list; position before/after a block, or start/end of a children list), remove (+through), replace (a block keeps its id; an attribute; a children list or \"\" with stubs {\"clientId\":…} to keep blocks), ",
239 "move/copy (from, +through, to_page for another page, position), wrap (value: the container), unwrap, split (at_text), merge (from into path). New blocks have no id; \"label\":\"$x\" names one for later ops in the call. ",
240 "Exercise blocks can only be moved, copied or removed here; patch_exercise edits an exercise's name, own deadline and instructions. Call guide for a block type's schema before writing it. read_back reads the result.",
241 staging_note!()
242 ),
243 write("Edit page content", true),
244 McpScope::MaterialStage,
245 )
246 .taking_ops(PatchTarget::PageContent)
247 .always_loaded()
248}
249
250pub fn patch_course_structure<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
252 define(
253 handler,
254 PatchTarget::CourseStructure.tool_name(),
255 concat!(
256 "Edits modules, chapters and pages: add (a module, a chapter with an automatic front page, or a page, optionally with blocks, and with pair_with: the id of the published page it translates in another language version of the course), remove (+through), replace fields (module name; chapter name, color, opens_at, deadline; page title, url_path, hidden; remove sets nullable fields to null), move (+through), copy (page or chapter, new ids), reorder (value: all current ids in the new order). ",
257 "Paths: /course/modules, /course/pages, /<module id>/chapters, /<chapter id>/pages, /<id>, /<id>/<field>. The first page of a chapter is its front page; the course front page (/) and the default module stay first. Chapter numbers and paths are recomputed after each call; changed paths get redirects automatically. The only way to create pages. dry_run: true returns an outline (outline: counts and one line per created, removed or changed chapter and page, with old and new paths, titles and positions; changed_paths: the redirects) instead of the full diff; detail: true adds the diff.",
258 staging_note!()
259 ),
260 write("Edit course structure", true),
261 McpScope::MaterialStage,
262 )
263 .taking_ops(PatchTarget::CourseStructure)
264}
265
266pub fn patch_course_settings<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
268 define(
269 handler,
270 PatchTarget::CourseSettings.tool_name(),
271 concat!(
272 "Edits settings: replace /course/<field> (name, description, is_unlisted, chapter_locking_enabled, ai_policy, base_module_completion_requires_n_submodule_completions), add to /course/audiences (value {\"name\"}), remove /<audience id>, replace /<module id>/completion_policy or one of its fields (note the spelling \"treshold\"), /<module id>/certification_enabled. ",
273 "Draft, test-mode and closing switches are read-only for agents. Module names are edited with patch_course_structure.",
274 staging_note!()
275 ),
276 write("Edit course settings", true),
277 McpScope::SettingsWrite,
278 )
279 .taking_ops(PatchTarget::CourseSettings)
280}
281
282pub fn patch_course_glossary_and_references<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
284 define(
285 handler,
286 PatchTarget::GlossaryAndReferences.tool_name(),
287 concat!(
288 "Edits glossary terms and references: add to /glossary (value {\"term\",\"definition\"}) or /references (value {\"citation_key\",\"bibtex\"}), replace /<id>/<field> or /<id> with an object, remove /<id>. ",
289 "A reference's bibtex holds one entry whose key equals citation_key. Renaming a citation_key rewrites its \\cite{…} citations in pages; other changes only list the pages that use the term or key.",
290 staging_note!()
291 ),
292 write("Edit glossary and references", true),
293 McpScope::MaterialStage,
294 )
295 .taking_ops(PatchTarget::GlossaryAndReferences)
296}
297
298pub fn patch_exercise<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
300 define(
301 handler,
302 "patch_exercise",
303 concat!(
304 "Edits an exercise's name, own deadline and instructions; its other settings are read-only. deadline overrides the chapter's; null clears it so the chapter's applies. Prefer chapter deadlines (patch_course_structure) and ask the teacher before adding an own one; making the deadline that applies earlier, or adding one, to an open exercise is risky. ops are patch_page_content ops with paths starting at /tasks/<task id> (that task's instructions) or /review_instructions; to_page isn't available. ",
305 "Review instructions of an exercise using the course default review config are shared: set review_config edit_course_default to edit them for every exercise using it (preferred), or fork to give only this exercise its own copy of the default, for an exercise with special needs. use_course_default drops the exercise's own review settings. The result warns when students have already given reviews, or answered an exercise whose task instructions the call edits.",
306 staging_note!()
307 ),
308 write("Edit exercise", true),
309 McpScope::MaterialStage,
310 )
311 .taking_ops(PatchTarget::PageContent)
312}
313
314pub fn find_replace<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
316 define(
317 handler,
318 "find_replace",
319 "Searches, and with replace changes, text across a course, chapter or page. Matches the stored HTML of text attributes (content, captions, alt texts, titles…), so markup counts: <em>for</em> loops doesn't match \"for loops\". Literal finds match whole words at word edges; regex: true takes a Rust regex (no backreferences or lookaround; (?i) for case-insensitive) and $1-style replacements. attributes picks attribute names or /regex/ on names. expected_count fails the call on any other count. Lists likely inflected forms it didn't change. Use dry_run: true first. Replacing needs the material:stage scope. Searches exercises' task and review instructions too (include_exercise_instructions, default true) and, with include_exercise_names, their names; matches in the course default review instructions are listed once under course_default_review_config and change it for every exercise using it.",
320 write("Find and replace text", true),
321 McpScope::MaterialRead,
322 )
323}
324
325pub fn structural_replace<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
327 define(
328 handler,
329 "structural_replace",
330 "Finds blocks by type and visible text (match: name, text_starts_with, text_contains, text_regex, Rust regex syntax: no backreferences or lookaround) across a course, chapter or page, and applies one action to each: wrap_in a container type, retype_to another type, unwrap: true or remove: true. Includes exercises' task and review instructions unless include_exercise_instructions is false. Use dry_run: true and expected_count. Stages like other writes.",
331 write("Replace blocks by pattern", true),
332 McpScope::MaterialStage,
333 )
334}
335
336pub fn upload_file<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
338 define(
339 handler,
340 "upload_file",
341 "Uploads an image or file for the course and returns its URL, to use in image or file block attributes.",
342 write("Upload a file", false),
343 McpScope::MaterialStage,
344 )
345}
346
347pub fn list_changesets<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
349 define(
350 handler,
351 "list_changesets",
352 "Lists your changesets: the open one per course with its staged pages and entities, risky items and the review link, plus recently published ones.",
353 read("List changesets"),
354 McpScope::MaterialRead,
355 )
356}
357
358pub fn publish_changeset<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
360 define(
361 handler,
362 "publish_changeset",
363 "Publishes your open changeset so students see it. Refused when it changed since the revision you give (another session may share it), when it holds risky changes (only the teacher publishes those, on the review page given in the result) or when a staged page or entity changed live since it was staged (the error says how to redo those).",
364 write("Publish changeset", true),
365 McpScope::MaterialPublish,
366 )
367}
368
369pub fn discard_changeset<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
371 define(
372 handler,
373 "discard_changeset",
374 "Drops staged changes from your open changeset, the undo before publishing: every change, or with entities only those entities, which then show their published state again.",
375 write("Discard changeset", true).idempotent(true),
376 McpScope::MaterialStage,
377 )
378}
379
380pub fn get_preview_link<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
382 define(
383 handler,
384 "get_preview_link",
385 "Returns read-only preview URLs, valid about 2 hours without login, for visual checks in a browser: of staged state, or of the published pages when nothing is staged (so you can check before staging). With neither page nor pages, one url previews every page the changeset adds or changes, or every page of the course when nothing is staged, with links to switch between them (prefer it to several links); page gives one url for that page, pages gives previews, a {page, url} per page.",
386 read("Get preview link"),
387 McpScope::MaterialRead,
388 )
389}
390
391pub fn get_history<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
393 define(
394 handler,
395 "get_history",
396 "Lists your own recent write calls in a course, or those touching one entity: when, through which client, and the call id that revert_change takes. Other people's changes aren't listed; with entity, last_published_change tells when the entity was last published and by whom, any author or the web editor included.",
397 read("Read change history"),
398 McpScope::MaterialRead,
399 )
400}
401
402pub fn revert_change<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
404 define(
405 handler,
406 "revert_change",
407 "Undoes one published write call by staging the versions from just before it. Rejected if an entity changed again since; the error lists them. The revert stages like any write.",
408 write("Revert a change", true),
409 McpScope::MaterialStage,
410 )
411}
412
413pub fn guide<S>(handler: impl ToolHandler<S> + 'static) -> Tool<S> {
415 define(
416 handler,
417 "guide",
418 "Returns how to write content: per block type (block_types) the attribute schema, nesting rules, allowed rich-text formats and an example add op; per topic (topics) a short guide. No arguments lists the topics and block types per page type. Call it before creating or changing a block type for the first time.",
419 read("Guide"),
420 McpScope::MaterialRead,
421 )
422 .always_loaded()
423 .usable_without_skill()
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 fn definitions() -> Vec<ToolDefinition> {
431 crate::handlers::registry().definitions().cloned().collect()
432 }
433
434 const MAX_SCHEMA_BYTES: usize = 4_500;
435
436 fn walk(value: &Value, visit: &mut dyn FnMut(&str, &Value)) {
437 if let Value::Object(map) = value {
438 for (key, child) in map {
439 visit(key, child);
440 walk(child, visit);
441 }
442 } else if let Value::Array(items) = value {
443 items.iter().for_each(|i| walk(i, visit));
444 }
445 }
446
447 #[test]
448 fn every_input_schema_is_small_and_flat() {
449 for tool in definitions() {
450 let bytes = serde_json::to_vec(&*tool.listing.input_schema)
451 .unwrap()
452 .len();
453 assert!(
454 bytes < MAX_SCHEMA_BYTES,
455 "{} input schema is {bytes} bytes",
456 tool.name
457 );
458 let schema = Value::Object((*tool.listing.input_schema).clone());
459 walk(&schema, &mut |key, value| {
460 assert!(
461 !matches!(key, "oneOf" | "anyOf" | "allOf" | "$ref" | "$defs"),
462 "{} uses {key}",
463 tool.name
464 );
465 if key == "type" {
466 assert!(value.is_string(), "{} has a type array", tool.name);
467 }
468 });
469 assert_eq!(tool.listing.input_schema["type"], json!("object"));
470 }
471 }
472
473 #[test]
474 fn fixed_order_unique_names_and_skill_version_everywhere() {
475 let tools = definitions();
476 let names: Vec<_> = tools.iter().map(|t| t.name).collect();
477 assert_eq!(names.first(), Some(&"list_courses"));
478 assert_eq!(names.last(), Some(&"guide"));
479 assert_eq!(names.len(), 23);
480 let mut unique = names.clone();
481 unique.sort_unstable();
482 unique.dedup();
483 assert_eq!(unique.len(), names.len());
484 for tool in &tools {
485 let properties = tool.listing.input_schema["properties"].as_object().unwrap();
486 assert!(properties.contains_key("skill_version"), "{}", tool.name);
487 let required = tool
488 .listing
489 .input_schema
490 .get("required")
491 .and_then(Value::as_array)
492 .cloned()
493 .unwrap_or_default();
494 assert!(!required.contains(&json!("skill_version")));
495 }
496 }
497
498 #[test]
499 fn ops_are_a_flat_struct_with_untyped_value() {
500 let tool = definitions()
501 .into_iter()
502 .find(|t| t.name == "patch_page_content")
503 .unwrap();
504 let op = &tool.listing.input_schema["properties"]["ops"]["items"];
505 assert_eq!(op["properties"]["op"]["enum"].as_array().unwrap().len(), 9);
506 assert!(
507 !op["properties"]["op"]["enum"]
508 .as_array()
509 .unwrap()
510 .contains(&json!("reorder"))
511 );
512 assert!(op["properties"]["value"].get("type").is_none());
513 assert_eq!(op["required"], json!(["op", "path"]));
514 }
515
516 #[test]
517 fn course_is_promoted_to_a_header() {
518 let tool = definitions()
519 .into_iter()
520 .find(|t| t.name == "patch_page_content")
521 .unwrap();
522 assert_eq!(
523 tool.header_params,
524 vec![("course".to_string(), "Course".to_string())]
525 );
526 }
527
528 #[test]
529 fn reads_are_read_only_and_idempotent() {
530 const NONDETERMINISTIC_READS: [&str; 1] = ["read_public_course"];
532 for tool in definitions() {
533 let annotations = &serde_json::to_value(&tool.listing).unwrap()["annotations"];
534 let is_read_only = annotations["readOnlyHint"] == json!(true);
535 let is_named_as_read = tool.name.starts_with("get_")
536 || tool.name.starts_with("list_")
537 || tool.name.starts_with("read_")
538 || tool.name == "guide";
539 assert_eq!(is_read_only, is_named_as_read, "{}", tool.name);
540 if is_read_only {
541 assert_eq!(
542 annotations["idempotentHint"],
543 json!(!NONDETERMINISTIC_READS.contains(&tool.name)),
544 "{}",
545 tool.name
546 );
547 }
548 }
549 }
550}