Skip to main content

headless_lms_mcp/tools/
catalog.rs

1//! Every tool's name, description, annotations and scope. The input schema comes from the
2//! handler's arguments.
3
4use 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/// What dispatch needs of a tool besides its handler.
15#[derive(Debug, Clone)]
16pub struct ToolDefinition {
17    pub name: &'static str,
18    /// The token needs this scope to see and call the tool.
19    pub scope: McpScope,
20    /// Whether calls without `skill_version` are refused; see [`crate::hints::skill_missing`].
21    pub requires_skill: bool,
22    /// The arguments the tool promotes to `Mcp-Param-*` headers, with the header suffix.
23    pub header_params: Vec<(String, String)>,
24    /// The tool as `tools/list` lists it.
25    pub listing: rmcp::model::Tool,
26}
27
28impl<S> Tool<S> {
29    /// Lets agents without the skill call the tool.
30    fn usable_without_skill(mut self) -> Self {
31        self.definition.requires_skill = false;
32        self
33    }
34
35    /// Asks Claude Code to load the tool up front instead of on demand.
36    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    /// Narrows the shared op enum of the input schema to the ops `target` takes.
46    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
74/// How every patch tool stages, appended to their descriptions.
75macro_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    // Dispatch takes `skill_version` out before the handler parses its arguments.
90    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
139/// The `list_courses` tool, run by `handler`.
140pub 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
151/// The `get_course_structure` tool, run by `handler`.
152pub 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
163/// The `get_page_content` tool, run by `handler`.
164pub 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
175/// The `get_course_settings` tool, run by `handler`.
176pub 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
186/// The `get_course_glossary_and_references` tool, run by `handler`.
187pub 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
197/// The `get_exercise` tool, run by `handler`.
198pub 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
208/// The `list_exercises` tool, run by `handler`.
209pub 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
219/// The `read_public_course` tool, run by `handler`.
220pub 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        // Not idempotent: the shown slide is random until one is chosen for the caller.
226        read("Read a public course").idempotent(false),
227        McpScope::MaterialRead,
228    )
229}
230
231/// The `patch_page_content` tool, run by `handler`.
232pub 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
250/// The `patch_course_structure` tool, run by `handler`.
251pub 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
266/// The `patch_course_settings` tool, run by `handler`.
267pub 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
282/// The `patch_course_glossary_and_references` tool, run by `handler`.
283pub 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
298/// The `patch_exercise` tool, run by `handler`.
299pub 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
314/// The `find_replace` tool, run by `handler`.
315pub 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
325/// The `structural_replace` tool, run by `handler`.
326pub 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
336/// The `upload_file` tool, run by `handler`.
337pub 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
347/// The `list_changesets` tool, run by `handler`.
348pub 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
358/// The `publish_changeset` tool, run by `handler`.
359pub 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
369/// The `discard_changeset` tool, run by `handler`.
370pub 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
380/// The `get_preview_link` tool, run by `handler`.
381pub 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
391/// The `get_history` tool, run by `handler`.
392pub 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
402/// The `revert_change` tool, run by `handler`.
403pub 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
413/// The `guide` tool, run by `handler`.
414pub 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        // Shows a random exercise slide until one is chosen for the caller.
531        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}