Skip to main content

noyalib_mcp/
tools.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! The six tools, as plain functions and as MCP tools.
5//!
6//! [`get`], [`set`], [`set_multidoc`], [`parse`], [`edit`] and
7//! [`validate`] are the YAML work; nothing in them knows about MCP.
8//! The `#[tool_router]` block at the bottom is what `tools/list`
9//! advertises and `tools/call` reaches: each tool deserialises its
10//! arguments, calls the function, and returns the answer as text for
11//! the model and as `structuredContent` for the client. Every edit
12//! goes through noyalib's `cst::Document`, so the untouched bytes of a
13//! file -- comments, indentation, sibling entries -- survive.
14
15use std::fmt;
16#[cfg(test)]
17use std::fs;
18use std::path::Path;
19
20use noyalib::cst::{parse_document_with_config, parse_stream_with_config};
21use rmcp::handler::server::tool::schema_for_output;
22use rmcp::handler::server::wrapper::Parameters;
23use rmcp::model::{CallToolResult, ContentBlock, ErrorData};
24use rmcp::{tool, tool_router};
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use serde_json::Value as JsonValue;
28
29use crate::YamlServer;
30use crate::fsio::{FileError, Kept, Located, RootDir};
31
32// --- Outputs -------------------------------------------------------------
33
34/// The value `noyalib_get` read.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
36pub struct GetOutput {
37    /// The source slice at the path, exactly as written: no
38    /// re-quoting, no canonicalisation. Empty for a key with no value.
39    pub value: String,
40}
41
42impl fmt::Display for GetOutput {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.value)
45    }
46}
47
48/// What `noyalib_set` wrote.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
50pub struct SetOutput {
51    /// The file that was rewritten.
52    pub file: String,
53    /// The path that was set.
54    pub path: String,
55    /// The YAML fragment now at that path.
56    pub value: String,
57}
58
59impl fmt::Display for SetOutput {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(
62            f,
63            "set {} = {} in {} (lossless: comments and formatting preserved)",
64            self.path, self.value, self.file
65        )
66    }
67}
68
69/// What `noyalib_set_multidoc` wrote.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
71pub struct SetMultidocOutput {
72    /// The file that was rewritten.
73    pub file: String,
74    /// The zero-based index of the document that changed.
75    pub doc_index: usize,
76    /// The path that was set, within that document.
77    pub path: String,
78    /// The YAML fragment now at that path.
79    pub value: String,
80}
81
82impl fmt::Display for SetMultidocOutput {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(
85            f,
86            "set {} = {} in document {} of {} \
87             (lossless: other documents, comments and formatting preserved)",
88            self.path, self.value, self.doc_index, self.file
89        )
90    }
91}
92
93/// The JSON data model of the YAML `noyalib_parse` was given.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
95pub struct ParseOutput {
96    /// One JSON value per document of the stream, custom tags
97    /// stripped. A single-document text gives one element.
98    pub documents: Vec<JsonValue>,
99}
100
101impl fmt::Display for ParseOutput {
102    /// A single document prints as itself, a stream as an array: the
103    /// projection the official YAML test suite expects.
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        let text = match self.documents.as_slice() {
106            [one] => serde_json::to_string_pretty(one),
107            many => serde_json::to_string_pretty(many),
108        }
109        .map_err(|_| fmt::Error)?;
110        f.write_str(&text)
111    }
112}
113
114/// The text `noyalib_edit` produced.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
116pub struct EditOutput {
117    /// The whole YAML text with the one value replaced.
118    pub yaml: String,
119}
120
121impl fmt::Display for EditOutput {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.write_str(&self.yaml)
124    }
125}
126
127/// One JSON Schema violation.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
129pub struct Violation {
130    /// The RFC 6901 path of the offending value.
131    pub path: String,
132    /// The schema keyword that failed.
133    pub keyword: String,
134    /// What is wrong with it.
135    pub message: String,
136}
137
138/// The verdict of `noyalib_validate`.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
140pub struct ValidateOutput {
141    /// Whether the text parses and, when a schema was given, satisfies
142    /// it.
143    pub valid: bool,
144    /// The parse error, when the text does not parse.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub error: Option<String>,
147    /// The line of the parse error, when there is one.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub line: Option<usize>,
150    /// The column of the parse error, when there is one.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub column: Option<usize>,
153    /// Every schema violation; empty when the document is valid or
154    /// no schema was given.
155    pub violations: Vec<Violation>,
156}
157
158impl fmt::Display for ValidateOutput {
159    /// The verdict as JSON, which is what the previous release printed
160    /// and what a model parses back.
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        let text = serde_json::to_string(self).map_err(|_| fmt::Error)?;
163        f.write_str(&text)
164    }
165}
166
167// --- Parse profile ------------------------------------------------------
168
169/// The rules every tool parses under.
170///
171/// The server's input is whatever a client sends, so the default is
172/// noyalib's strict YAML 1.2 profile, the one built for untrusted
173/// input: duplicate keys are an error rather than last-wins, only
174/// `true` and `false` are booleans, indentation must be even, and the
175/// tighter resource limits apply. `--profile standard` restores the
176/// library defaults. The file tools and `noyalib_edit` parse through
177/// the lossless CST under the same rules and limits, and the profile's
178/// document limit also caps the size of a file read and of YAML text
179/// in a request.
180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
181pub enum ParseProfile {
182    /// `ParserConfig::strict()`.
183    #[default]
184    Strict,
185    /// `ParserConfig::default()`, the library's YAML 1.2 defaults.
186    Standard,
187}
188
189impl ParseProfile {
190    /// Parse a `--profile` value.
191    #[must_use]
192    pub fn from_name(name: &str) -> Option<Self> {
193        match name {
194            "strict" => Some(Self::Strict),
195            "standard" => Some(Self::Standard),
196            _ => None,
197        }
198    }
199
200    /// The parser configuration this profile stands for.
201    #[must_use]
202    pub fn config(self) -> noyalib::ParserConfig {
203        match self {
204            Self::Strict => noyalib::ParserConfig::strict(),
205            Self::Standard => noyalib::ParserConfig::default(),
206        }
207    }
208}
209
210// --- The functions -------------------------------------------------------
211
212/// Read the value at `path` in the YAML file `file`.
213///
214/// The value is the source slice, exactly as written. A key with no
215/// value (`key:`) reads as the empty string rather than as a missing
216/// path.
217///
218/// # Errors
219///
220/// The file cannot be read, does not parse, or has no such path. The
221/// message says which.
222pub fn get(file: &str, path: &str) -> Result<GetOutput, String> {
223    get_at(
224        &locate_unconfined(file)?,
225        file,
226        path,
227        &noyalib::ParserConfig::default(),
228    )
229}
230
231/// Locate a file the library functions were given, with no root.
232fn locate_unconfined(file: &str) -> Result<Located, String> {
233    Located::unconfined(Path::new(file)).map_err(|e| format!("read {file}: {e}"))
234}
235
236/// Read a located file within `config`'s document size limit.
237fn read_at(
238    at: &Located,
239    file: &str,
240    config: &noyalib::ParserConfig,
241) -> Result<(String, Kept), String> {
242    at.read(config.max_document_length)
243        .map_err(|e| format!("read {file}: {e}"))
244}
245
246/// [`get`] on a file already located; `file` is how to name it.
247pub(crate) fn get_at(
248    at: &Located,
249    file: &str,
250    path: &str,
251    config: &noyalib::ParserConfig,
252) -> Result<GetOutput, String> {
253    let (src, _) = read_at(at, file, config)?;
254    let doc = parse_document_with_config(&src, config).map_err(|e| format!("parse {file}: {e}"))?;
255    match doc.get(path) {
256        Some(value) => Ok(GetOutput {
257            value: value.to_string(),
258        }),
259        // `get` yields `None` for an implicit null (`key:` with no
260        // value) as well as for a missing path; the key span tells
261        // the two apart so an empty value reads as its (empty) source
262        // slice instead of a spurious "not found".
263        None if doc.key_span(path).is_some() => Ok(GetOutput {
264            value: String::new(),
265        }),
266        None => Err(format!("path not found in {}: {}", clip(file), clip(path))),
267    }
268}
269
270/// Set the value at `path` in the YAML file `file` to the fragment
271/// `value`, rewriting only the touched span and writing atomically.
272///
273/// # Errors
274///
275/// The file cannot be read or written, does not parse, or the fragment
276/// cannot be applied at the path. The file is unchanged on any of
277/// them.
278pub fn set(file: &str, path: &str, value: &str) -> Result<SetOutput, String> {
279    set_at(
280        &locate_unconfined(file)?,
281        file,
282        path,
283        value,
284        &noyalib::ParserConfig::default(),
285    )
286}
287
288/// [`set`] on a file already located; `file` is how to name it.
289pub(crate) fn set_at(
290    at: &Located,
291    file: &str,
292    path: &str,
293    value: &str,
294    config: &noyalib::ParserConfig,
295) -> Result<SetOutput, String> {
296    let (src, kept) = read_at(at, file, config)?;
297    check_fragment(value, config)?;
298    let mut doc =
299        parse_document_with_config(&src, config).map_err(|e| format!("parse {file}: {e}"))?;
300    doc.set(path, value)
301        .map_err(|e| set_failed(path, value, &e))?;
302    at.replace(doc.to_string().as_bytes(), kept)
303        .map_err(|e| format!("write {file}: {e}"))?;
304    Ok(SetOutput {
305        file: file.to_owned(),
306        path: path.to_owned(),
307        value: value.to_owned(),
308    })
309}
310
311/// [`set`] for document `doc_index` of a `---`-separated stream.
312///
313/// # Errors
314///
315/// As [`set`], or the index is out of range. The file is unchanged on
316/// any of them.
317pub fn set_multidoc(
318    file: &str,
319    doc_index: usize,
320    path: &str,
321    value: &str,
322) -> Result<SetMultidocOutput, String> {
323    let at = locate_unconfined(file)?;
324    let config = noyalib::ParserConfig::default();
325    set_multidoc_at(&at, file, doc_index, path, value, &config)
326}
327
328/// [`set_multidoc`] on a file already located; `file` is how to name
329/// it.
330pub(crate) fn set_multidoc_at(
331    at: &Located,
332    file: &str,
333    doc_index: usize,
334    path: &str,
335    value: &str,
336    config: &noyalib::ParserConfig,
337) -> Result<SetMultidocOutput, String> {
338    let (src, kept) = read_at(at, file, config)?;
339    // parse_stream keeps each `---`-delimited document as its own
340    // lossless Document, retaining its separator; concatenating their
341    // rendered forms reproduces the stream byte-for-byte, so editing one
342    // document leaves every other document untouched.
343    let mut docs =
344        parse_stream_with_config(&src, config).map_err(|e| format!("parse {file}: {e}"))?;
345    if doc_index >= docs.len() {
346        return Err(format!(
347            "doc_index {doc_index} out of range: stream has {} document(s)",
348            docs.len()
349        ));
350    }
351    check_fragment(value, config)?;
352    docs[doc_index]
353        .set(path, value)
354        .map_err(|e| set_failed(path, value, &e))?;
355    let out: String = docs.iter().map(ToString::to_string).collect();
356    at.replace(out.as_bytes(), kept)
357        .map_err(|e| format!("write {file}: {e}"))?;
358    Ok(SetMultidocOutput {
359        file: file.to_owned(),
360        doc_index,
361        path: path.to_owned(),
362        value: value.to_owned(),
363    })
364}
365
366/// Parse YAML text into its JSON data model. Nothing on disk is read.
367///
368/// # Errors
369///
370/// The text does not parse under the library's limits.
371pub fn parse(yaml: &str) -> Result<ParseOutput, String> {
372    parse_with_profile(yaml, ParseProfile::default())
373}
374
375/// [`parse`] under an explicit [`ParseProfile`].
376///
377/// # Errors
378///
379/// The text does not parse under the profile's rules and limits.
380pub fn parse_with_profile(yaml: &str, profile: ParseProfile) -> Result<ParseOutput, String> {
381    check_text(yaml, &profile.config())?;
382    let docs: Vec<noyalib::Value> = noyalib::load_all_with_config(yaml, &profile.config())
383        .and_then(Iterator::collect)
384        .map_err(|e| format!("parse: {e}"))?;
385    let documents = docs
386        .into_iter()
387        .map(|d| serde_json::to_value(d.untag()).map_err(internal))
388        .collect::<Result<_, _>>()?;
389    Ok(ParseOutput { documents })
390}
391
392/// Set one value in YAML text and return the whole edited text.
393/// Nothing on disk is touched. The text is parsed under the library's
394/// default limits ([`ParseProfile::Standard`]).
395///
396/// # Errors
397///
398/// The text does not parse, or the fragment cannot be applied at the
399/// path.
400pub fn edit(yaml: &str, path: &str, value: &str) -> Result<EditOutput, String> {
401    edit_with_profile(yaml, path, value, ParseProfile::Standard)
402}
403
404/// [`edit`] under an explicit [`ParseProfile`]: its document size and
405/// nesting limits apply to the text and to the fragment.
406///
407/// # Errors
408///
409/// The text or the fragment is over the profile's limits, the text
410/// does not parse, or the fragment cannot be applied at the path.
411pub fn edit_with_profile(
412    yaml: &str,
413    path: &str,
414    value: &str,
415    profile: ParseProfile,
416) -> Result<EditOutput, String> {
417    let config = profile.config();
418    check_text(yaml, &config)?;
419    check_fragment(value, &config)?;
420    let mut doc = parse_document_with_config(yaml, &config).map_err(|e| format!("parse: {e}"))?;
421    doc.set(path, value)
422        .map_err(|e| set_failed(path, value, &e))?;
423    Ok(EditOutput {
424        yaml: doc.to_string(),
425    })
426}
427
428mod limits;
429
430pub use limits::MAX_FRAGMENT_BYTES;
431#[cfg(test)]
432use limits::nesting_depth;
433use limits::{check_fragment, check_text, clip, set_failed};
434
435/// Check that YAML text parses and, when `schema` is given, that it
436/// satisfies that JSON Schema.
437///
438/// A text that does not parse, or a document that violates the schema,
439/// is a verdict with `valid: false`, not an error: the location or the
440/// violations are the answer.
441///
442/// # Errors
443///
444/// The schema is not JSON or not a valid schema. Nothing was
445/// validated, so there is no verdict.
446pub fn validate(yaml: &str, schema: Option<&str>) -> Result<ValidateOutput, String> {
447    validate_with_profile(yaml, schema, ParseProfile::default())
448}
449
450/// [`validate`] under an explicit [`ParseProfile`].
451///
452/// # Errors
453///
454/// The schema is not valid JSON or not a valid JSON Schema. A document
455/// that fails to parse or to validate is a result, not an error.
456pub fn validate_with_profile(
457    yaml: &str,
458    schema: Option<&str>,
459    profile: ParseProfile,
460) -> Result<ValidateOutput, String> {
461    let value = match parse_for_validation(yaml, &profile.config()) {
462        Ok(v) => v,
463        Err(verdict) => return Ok(verdict),
464    };
465    let violations = match schema {
466        Some(schema_text) => schema_violations(&value, schema_text)?,
467        None => Vec::new(),
468    };
469    Ok(ValidateOutput {
470        valid: violations.is_empty(),
471        error: None,
472        line: None,
473        column: None,
474        violations,
475    })
476}
477
478/// The document `noyalib_validate` checks, or the verdict that it does
479/// not parse.
480fn parse_for_validation(
481    yaml: &str,
482    config: &noyalib::ParserConfig,
483) -> Result<noyalib::Value, ValidateOutput> {
484    let failed = |error: String, line, column| ValidateOutput {
485        valid: false,
486        error: Some(error),
487        line: Some(line),
488        column: Some(column),
489        violations: Vec::new(),
490    };
491    check_text(yaml, config).map_err(|e| failed(e, 0, 0))?;
492    noyalib::from_str_with_config::<noyalib::Value>(yaml, config).map_err(|e| {
493        let (line, column) = e.location().map_or((0, 0), |l| (l.line(), l.column()));
494        failed(e.to_string(), line, column)
495    })
496}
497
498/// The largest JSON Schema `noyalib_validate` compiles, in bytes.
499pub const MAX_SCHEMA_BYTES: usize = 64 * 1024;
500
501/// How many violations a verdict lists. Past it, one more entry says
502/// the list was cut short. The core stops collecting one past this cap,
503/// so a verdict never costs more than `MAX_VIOLATIONS + 1` violations.
504pub const MAX_VIOLATIONS: usize = 100;
505
506/// Compile a client's JSON Schema, refusing one over
507/// [`MAX_SCHEMA_BYTES`] before it is parsed.
508fn compile_schema(schema_text: &str) -> Result<noyalib::CompiledSchema, String> {
509    if schema_text.len() > MAX_SCHEMA_BYTES {
510        return Err(format!(
511            "the schema is {} bytes, over the {MAX_SCHEMA_BYTES}-byte schema limit",
512            schema_text.len()
513        ));
514    }
515    let schema: JsonValue =
516        serde_json::from_str(schema_text).map_err(|e| format!("schema is not JSON: {e}"))?;
517    let schema_value: noyalib::Value =
518        serde_json::from_value(schema).map_err(|e| format!("schema: {e}"))?;
519    noyalib::CompiledSchema::builder(&schema_value)
520        .max_errors(MAX_VIOLATIONS + 1)
521        .build()
522        .map_err(|e| format!("schema: {e}"))
523}
524
525/// The violations of the JSON Schema `schema_text` by `value`: the
526/// first [`MAX_VIOLATIONS`] of them with their messages clipped, and a
527/// `truncated` entry when there were more.
528fn schema_violations(value: &noyalib::Value, schema_text: &str) -> Result<Vec<Violation>, String> {
529    let compiled = compile_schema(schema_text)?;
530    let all = compiled.iter_errors(value).map_err(internal)?;
531    let mut violations: Vec<Violation> = all
532        .iter()
533        .take(MAX_VIOLATIONS)
534        .map(|v| Violation {
535            path: clip(&v.instance_path).into_owned(),
536            keyword: v.keyword.clone(),
537            message: clip(&v.message).into_owned(),
538        })
539        .collect();
540    if all.len() > MAX_VIOLATIONS {
541        violations.push(Violation {
542            path: String::new(),
543            keyword: "truncated".to_owned(),
544            message: format!(
545                "more than {MAX_VIOLATIONS} violations; the first {MAX_VIOLATIONS} are listed"
546            ),
547        });
548    }
549    Ok(violations)
550}
551
552/// An error no request can provoke (a JSON conversion of a value the
553/// parser already accepted): one function, so the unreachable paths do
554/// not each count as an uncovered closure.
555fn internal(e: impl fmt::Display) -> String {
556    format!("internal: {e}")
557}
558
559/// Find a `file` argument under the server root.
560pub(crate) fn locate(root: &RootDir, file: &str) -> Result<Located, String> {
561    root.locate(Path::new(file)).map_err(|e| match e {
562        FileError::Outside => outside(file),
563        FileError::Io(e) => format!("read {file}: {e}"),
564    })
565}
566
567/// The one answer for a path outside the root, whatever is there.
568pub(crate) fn outside(file: &str) -> String {
569    format!("{file} is outside the server root; start noyalib-mcp with --root to allow it")
570}
571
572/// How long one tool call may run before the client is answered with
573/// an error. See [`YamlServer::with_call_timeout`].
574pub const DEFAULT_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
575
576/// Run a tool's work on the blocking pool, off the async workers, and
577/// answer with an error if it outlasts `limit`.
578///
579/// File I/O and parsing block. On a worker thread a slow call would
580/// stall every other request on that worker, `ping` included. A call
581/// past its time limit is answered at once; its thread cannot be
582/// interrupted and finishes in the background, its result discarded.
583async fn off_thread<T, F>(limit: std::time::Duration, work: F) -> Result<T, String>
584where
585    T: Send + 'static,
586    F: FnOnce() -> Result<T, String> + Send + 'static,
587{
588    match tokio::time::timeout(limit, tokio::task::spawn_blocking(work)).await {
589        Ok(joined) => joined.unwrap_or_else(|e| Err(internal(e))),
590        Err(_) => Err(format!(
591            "the call ran past the {} ms time limit and was abandoned",
592            limit.as_millis()
593        )),
594    }
595}
596
597// --- Arguments -----------------------------------------------------------
598//
599// The doc comments are the descriptions a client shows the model, kept
600// word for word from the previous release. The examples are what an
601// auditor sends when it has no file of its own.
602
603/// Arguments of `noyalib_get`.
604#[derive(Debug, Deserialize, JsonSchema)]
605pub struct GetArgs {
606    /// Path to the YAML file on disk.
607    #[schemars(example = &"config.yaml")]
608    pub file: String,
609    /// Dotted/indexed path into the YAML, e.g. `server.host` or
610    /// `items[0].name`.
611    #[schemars(example = &"server.host")]
612    pub path: String,
613}
614
615/// Arguments of `noyalib_set`.
616#[derive(Debug, Deserialize, JsonSchema)]
617pub struct SetArgs {
618    /// Path to the YAML file on disk.
619    #[schemars(example = &"config.yaml")]
620    pub file: String,
621    /// Dotted/indexed path into the YAML.
622    #[schemars(example = &"server.port")]
623    pub path: String,
624    /// Replacement value as a YAML fragment (e.g. `0.0.2`, `"hello"`,
625    /// `[1, 2, 3]`). Must parse in the target position; the document
626    /// is left unchanged on parse error.
627    #[schemars(example = &"9090")]
628    pub value: String,
629}
630
631/// Arguments of `noyalib_set_multidoc`.
632#[derive(Debug, Deserialize, JsonSchema)]
633pub struct SetMultidocArgs {
634    /// Path to the multi-document YAML file on disk.
635    #[schemars(example = &"manifests.yaml")]
636    pub file: String,
637    /// Zero-based index of the document within the `---`-separated
638    /// stream to modify.
639    #[schemars(example = &0, range(min = 0))]
640    pub doc_index: usize,
641    /// Dotted/indexed path into the selected document.
642    #[schemars(example = &"metadata.name")]
643    pub path: String,
644    /// Replacement value as a YAML fragment. Must parse in the target
645    /// position; the file is left unchanged on parse error.
646    #[schemars(example = &"api")]
647    pub value: String,
648}
649
650/// Arguments of `noyalib_parse`.
651#[derive(Debug, Deserialize, JsonSchema)]
652pub struct ParseArgs {
653    /// The YAML text to parse.
654    #[schemars(example = &"server:\n  host: api.example.com  # public\n  port: 8080\n")]
655    pub yaml: String,
656}
657
658/// Arguments of `noyalib_edit`.
659#[derive(Debug, Deserialize, JsonSchema)]
660pub struct EditArgs {
661    /// The YAML text to edit.
662    #[schemars(example = &"server:\n  host: api.example.com  # public\n  port: 8080\n")]
663    pub yaml: String,
664    /// Dotted/indexed path, e.g. `server.port` or `items[0].name`.
665    #[schemars(example = &"server.port")]
666    pub path: String,
667    /// Replacement value as a YAML fragment.
668    #[schemars(example = &"9090")]
669    pub value: String,
670}
671
672/// Arguments of `noyalib_validate`.
673#[derive(Debug, Deserialize, JsonSchema)]
674pub struct ValidateArgs {
675    /// The YAML text to validate.
676    #[schemars(example = &"server:\n  host: api.example.com  # public\n  port: 8080\n")]
677    pub yaml: String,
678    /// A JSON Schema, as JSON text (optional).
679    #[schemars(example = &"{\"type\":\"object\",\"required\":[\"server\"]}")]
680    #[serde(default)]
681    pub schema: Option<String>,
682}
683
684// --- The MCP tools -------------------------------------------------------
685
686/// A tool result carrying the same answer twice: as text for the
687/// model and as a structured value for the client.
688///
689/// A failure keeps the text only. The structured schema describes a
690/// result, and an error is not one.
691fn reply<T: Serialize + fmt::Display>(
692    outcome: Result<T, String>,
693    is_error: impl FnOnce(&T) -> bool,
694) -> Result<CallToolResult, ErrorData> {
695    match outcome {
696        Ok(value) => {
697            let structured = serde_json::to_value(&value)
698                .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
699            let content = vec![ContentBlock::text(value.to_string())];
700            let mut result = if is_error(&value) {
701                CallToolResult::error(content)
702            } else {
703                CallToolResult::success(content)
704            };
705            result.structured_content = Some(structured);
706            Ok(result)
707        }
708        // A tool that ran and could not do the job: a *successful*
709        // JSON-RPC response carrying `isError`, so the model sees the
710        // text and can react to it. A JSON-RPC error would be handled
711        // by the client and never shown.
712        Err(message) => Ok(CallToolResult::error(vec![ContentBlock::text(message)])),
713    }
714}
715
716/// The names of the tools, for the message an unknown name draws.
717pub const TOOL_NAMES: [&str; 6] = [
718    "noyalib_get",
719    "noyalib_set",
720    "noyalib_set_multidoc",
721    "noyalib_parse",
722    "noyalib_edit",
723    "noyalib_validate",
724];
725
726#[tool_router(vis = "pub(crate)")]
727#[allow(
728    clippy::unused_self,
729    reason = "the SDK's tool router calls tools as methods"
730)]
731impl YamlServer {
732    #[tool(
733        name = "noyalib_get",
734        title = "Read a YAML value (lossless)",
735        description = "Read the YAML value at a dotted/indexed path \
736                       in the given file and return the source slice exactly — no \
737                       re-quoting, no canonicalisation, comments and formatting \
738                       preserved. Use this to inspect a value before changing it; \
739                       use `noyalib_set` to write a value back losslessly.",
740        // Reads a caller-supplied YAML file without modifying it:
741        // read-only, idempotent, never destructive, and open-world (it
742        // touches the local filesystem).
743        annotations(
744            title = "Read a YAML value (lossless)",
745            read_only_hint = true,
746            destructive_hint = false,
747            idempotent_hint = true,
748            open_world_hint = true
749        ),
750        output_schema = schema_for_output::<GetOutput>()
751    )]
752    async fn noyalib_get(
753        &self,
754        Parameters(args): Parameters<GetArgs>,
755    ) -> Result<CallToolResult, ErrorData> {
756        let (root, config) = (self.root.clone(), self.profile.config());
757        let outcome = off_thread(self.call_timeout, move || {
758            let at = locate(&root, &args.file)?;
759            get_at(&at, &args.file, &args.path, &config)
760        });
761        reply(outcome.await, |_| false)
762    }
763
764    #[tool(
765        name = "noyalib_set",
766        title = "Write a YAML value (lossless)",
767        description = "Set the YAML value at a dotted/indexed path in \
768                       the given file, rewriting only the touched span so every \
769                       comment, blank line, and sibling entry is preserved \
770                       byte-for-byte (written atomically). Use this for \
771                       Renovate-style version bumps and config patches; use \
772                       `noyalib_get` first when you need to read the current \
773                       value. On a parse error the document is left unchanged.",
774        // Overwrites the value at a path in a caller-supplied file on
775        // disk: not read-only, and destructive (it replaces existing
776        // content in place). Re-running with the same arguments yields
777        // the same file state, so it is idempotent; it touches the
778        // filesystem, so it is open-world.
779        annotations(
780            title = "Write a YAML value (lossless)",
781            read_only_hint = false,
782            destructive_hint = true,
783            idempotent_hint = true,
784            open_world_hint = true
785        ),
786        output_schema = schema_for_output::<SetOutput>()
787    )]
788    async fn noyalib_set(
789        &self,
790        Parameters(args): Parameters<SetArgs>,
791    ) -> Result<CallToolResult, ErrorData> {
792        let (root, config) = (self.root.clone(), self.profile.config());
793        let outcome = off_thread(self.call_timeout, move || {
794            let at = locate(&root, &args.file)?;
795            set_at(&at, &args.file, &args.path, &args.value, &config)
796        });
797        reply(outcome.await, |_| false)
798    }
799
800    #[tool(
801        name = "noyalib_set_multidoc",
802        title = "Write a YAML value in one document of a multi-doc stream (lossless)",
803        description = "Set the YAML value at a dotted/indexed path within \
804                       a single document of a multi-document (`---`-separated) YAML \
805                       stream, selected by zero-based document index. Only the touched \
806                       span of that one document is rewritten; every other document, \
807                       comment, blank line and separator is preserved byte-for-byte \
808                       (written atomically). Use `noyalib_set` for a single-document \
809                       file. On a parse error or out-of-range index the file is left \
810                       unchanged.",
811        annotations(
812            title = "Write a YAML value in one document of a multi-doc stream (lossless)",
813            read_only_hint = false,
814            destructive_hint = true,
815            idempotent_hint = true,
816            open_world_hint = true
817        ),
818        output_schema = schema_for_output::<SetMultidocOutput>()
819    )]
820    async fn noyalib_set_multidoc(
821        &self,
822        Parameters(args): Parameters<SetMultidocArgs>,
823    ) -> Result<CallToolResult, ErrorData> {
824        let (root, config) = (self.root.clone(), self.profile.config());
825        let outcome = off_thread(self.call_timeout, move || {
826            let at = locate(&root, &args.file)?;
827            let SetMultidocArgs {
828                file,
829                doc_index,
830                path,
831                value,
832            } = &args;
833            set_multidoc_at(&at, file, *doc_index, path, value, &config)
834        });
835        reply(outcome.await, |_| false)
836    }
837
838    #[tool(
839        name = "noyalib_parse",
840        title = "Parse YAML text into JSON (stateless)",
841        description = "Parse the YAML text given in the request and return \
842                       its JSON data model (custom tags stripped, the projection the \
843                       official YAML test suite expects). Multi-document streams return \
844                       a JSON array with one element per document. Refuses hostile \
845                       input (nesting, alias expansion, size) and duplicate keys under \
846                       the strict YAML 1.2 profile, the same rules as \
847                       the library. Nothing is read from or written to disk.",
848        // Content-in-request: nothing on disk is read or written. Pure,
849        // read-only, idempotent, closed-world.
850        annotations(
851            title = "Parse YAML text into JSON (stateless)",
852            read_only_hint = true,
853            destructive_hint = false,
854            idempotent_hint = true,
855            open_world_hint = false
856        ),
857        output_schema = schema_for_output::<ParseOutput>()
858    )]
859    async fn noyalib_parse(
860        &self,
861        Parameters(args): Parameters<ParseArgs>,
862    ) -> Result<CallToolResult, ErrorData> {
863        let profile = self.profile();
864        let outcome = off_thread(self.call_timeout, move || {
865            parse_with_profile(&args.yaml, profile)
866        });
867        reply(outcome.await, |_| false)
868    }
869
870    #[tool(
871        name = "noyalib_edit",
872        title = "Edit a value in YAML text, losslessly (stateless)",
873        description = "Set the value at a dotted/indexed path in the YAML text \
874                       given in the request and return the whole edited text. Only the \
875                       touched span changes; every comment, blank line and quote style \
876                       elsewhere is preserved byte-for-byte. Nothing on disk is touched: \
877                       the caller decides where the result goes.",
878        annotations(
879            title = "Edit a value in YAML text, losslessly (stateless)",
880            read_only_hint = true,
881            destructive_hint = false,
882            idempotent_hint = true,
883            open_world_hint = false
884        ),
885        output_schema = schema_for_output::<EditOutput>()
886    )]
887    async fn noyalib_edit(
888        &self,
889        Parameters(args): Parameters<EditArgs>,
890    ) -> Result<CallToolResult, ErrorData> {
891        let profile = self.profile();
892        let outcome = off_thread(self.call_timeout, move || {
893            edit_with_profile(&args.yaml, &args.path, &args.value, profile)
894        });
895        reply(outcome.await, |_| false)
896    }
897
898    #[tool(
899        name = "noyalib_validate",
900        title = "Validate YAML text, optionally against a JSON Schema (stateless)",
901        description = "Check that the YAML text parses under the library's \
902                       limits, and when a JSON Schema (as JSON text) is given, that the \
903                       document satisfies it. Returns `valid` with an empty list, or the \
904                       parse error with its line and column, or every schema violation \
905                       with its RFC 6901 path. Nothing on disk is touched.",
906        annotations(
907            title = "Validate YAML text, optionally against a JSON Schema (stateless)",
908            read_only_hint = true,
909            destructive_hint = false,
910            idempotent_hint = true,
911            open_world_hint = false
912        ),
913        output_schema = schema_for_output::<ValidateOutput>()
914    )]
915    async fn noyalib_validate(
916        &self,
917        Parameters(args): Parameters<ValidateArgs>,
918    ) -> Result<CallToolResult, ErrorData> {
919        let profile = self.profile();
920        let outcome = off_thread(self.call_timeout, move || {
921            validate_with_profile(&args.yaml, args.schema.as_deref(), profile)
922        });
923        // An invalid document is a failure the model must see, so it
924        // is flagged `isError` -- but it is also a complete verdict, so
925        // the structured half is kept.
926        reply(outcome.await, |v| !v.valid)
927    }
928}
929
930#[cfg(test)]
931mod tests;