1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
36pub struct GetOutput {
37 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
50pub struct SetOutput {
51 pub file: String,
53 pub path: String,
55 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
71pub struct SetMultidocOutput {
72 pub file: String,
74 pub doc_index: usize,
76 pub path: String,
78 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
95pub struct ParseOutput {
96 pub documents: Vec<JsonValue>,
99}
100
101impl fmt::Display for ParseOutput {
102 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
116pub struct EditOutput {
117 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
129pub struct Violation {
130 pub path: String,
132 pub keyword: String,
134 pub message: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
140pub struct ValidateOutput {
141 pub valid: bool,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub error: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub line: Option<usize>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub column: Option<usize>,
153 pub violations: Vec<Violation>,
156}
157
158impl fmt::Display for ValidateOutput {
159 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
181pub enum ParseProfile {
182 #[default]
184 Strict,
185 Standard,
187}
188
189impl ParseProfile {
190 #[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 #[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
210pub 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
231fn locate_unconfined(file: &str) -> Result<Located, String> {
233 Located::unconfined(Path::new(file)).map_err(|e| format!("read {file}: {e}"))
234}
235
236fn 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
246pub(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 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
270pub 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
288pub(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
311pub 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
328pub(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 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
366pub fn parse(yaml: &str) -> Result<ParseOutput, String> {
372 parse_with_profile(yaml, ParseProfile::default())
373}
374
375pub 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
392pub fn edit(yaml: &str, path: &str, value: &str) -> Result<EditOutput, String> {
401 edit_with_profile(yaml, path, value, ParseProfile::Standard)
402}
403
404pub 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
435pub fn validate(yaml: &str, schema: Option<&str>) -> Result<ValidateOutput, String> {
447 validate_with_profile(yaml, schema, ParseProfile::default())
448}
449
450pub 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
478fn 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
498pub const MAX_SCHEMA_BYTES: usize = 64 * 1024;
500
501pub const MAX_VIOLATIONS: usize = 100;
505
506fn 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
525fn 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
552fn internal(e: impl fmt::Display) -> String {
556 format!("internal: {e}")
557}
558
559pub(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
567pub(crate) fn outside(file: &str) -> String {
569 format!("{file} is outside the server root; start noyalib-mcp with --root to allow it")
570}
571
572pub const DEFAULT_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
575
576async 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#[derive(Debug, Deserialize, JsonSchema)]
605pub struct GetArgs {
606 #[schemars(example = &"config.yaml")]
608 pub file: String,
609 #[schemars(example = &"server.host")]
612 pub path: String,
613}
614
615#[derive(Debug, Deserialize, JsonSchema)]
617pub struct SetArgs {
618 #[schemars(example = &"config.yaml")]
620 pub file: String,
621 #[schemars(example = &"server.port")]
623 pub path: String,
624 #[schemars(example = &"9090")]
628 pub value: String,
629}
630
631#[derive(Debug, Deserialize, JsonSchema)]
633pub struct SetMultidocArgs {
634 #[schemars(example = &"manifests.yaml")]
636 pub file: String,
637 #[schemars(example = &0, range(min = 0))]
640 pub doc_index: usize,
641 #[schemars(example = &"metadata.name")]
643 pub path: String,
644 #[schemars(example = &"api")]
647 pub value: String,
648}
649
650#[derive(Debug, Deserialize, JsonSchema)]
652pub struct ParseArgs {
653 #[schemars(example = &"server:\n host: api.example.com # public\n port: 8080\n")]
655 pub yaml: String,
656}
657
658#[derive(Debug, Deserialize, JsonSchema)]
660pub struct EditArgs {
661 #[schemars(example = &"server:\n host: api.example.com # public\n port: 8080\n")]
663 pub yaml: String,
664 #[schemars(example = &"server.port")]
666 pub path: String,
667 #[schemars(example = &"9090")]
669 pub value: String,
670}
671
672#[derive(Debug, Deserialize, JsonSchema)]
674pub struct ValidateArgs {
675 #[schemars(example = &"server:\n host: api.example.com # public\n port: 8080\n")]
677 pub yaml: String,
678 #[schemars(example = &"{\"type\":\"object\",\"required\":[\"server\"]}")]
680 #[serde(default)]
681 pub schema: Option<String>,
682}
683
684fn 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 Err(message) => Ok(CallToolResult::error(vec![ContentBlock::text(message)])),
713 }
714}
715
716pub 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 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 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 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 reply(outcome.await, |v| !v.valid)
927 }
928}
929
930#[cfg(test)]
931mod tests;