use std::fmt;
#[cfg(test)]
use std::fs;
use std::path::Path;
use noyalib::cst::{parse_document_with_config, parse_stream_with_config};
use rmcp::handler::server::tool::schema_for_output;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ContentBlock, ErrorData};
use rmcp::{tool, tool_router};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::YamlServer;
use crate::fsio::{FileError, Kept, Located, RootDir};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct GetOutput {
pub value: String,
}
impl fmt::Display for GetOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct SetOutput {
pub file: String,
pub path: String,
pub value: String,
}
impl fmt::Display for SetOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"set {} = {} in {} (lossless: comments and formatting preserved)",
self.path, self.value, self.file
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct SetMultidocOutput {
pub file: String,
pub doc_index: usize,
pub path: String,
pub value: String,
}
impl fmt::Display for SetMultidocOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"set {} = {} in document {} of {} \
(lossless: other documents, comments and formatting preserved)",
self.path, self.value, self.doc_index, self.file
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct ParseOutput {
pub documents: Vec<JsonValue>,
}
impl fmt::Display for ParseOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self.documents.as_slice() {
[one] => serde_json::to_string_pretty(one),
many => serde_json::to_string_pretty(many),
}
.map_err(|_| fmt::Error)?;
f.write_str(&text)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct EditOutput {
pub yaml: String,
}
impl fmt::Display for EditOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.yaml)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct Violation {
pub path: String,
pub keyword: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct ValidateOutput {
pub valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
pub violations: Vec<Violation>,
}
impl fmt::Display for ValidateOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = serde_json::to_string(self).map_err(|_| fmt::Error)?;
f.write_str(&text)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ParseProfile {
#[default]
Strict,
Standard,
}
impl ParseProfile {
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"strict" => Some(Self::Strict),
"standard" => Some(Self::Standard),
_ => None,
}
}
#[must_use]
pub fn config(self) -> noyalib::ParserConfig {
match self {
Self::Strict => noyalib::ParserConfig::strict(),
Self::Standard => noyalib::ParserConfig::default(),
}
}
}
pub fn get(file: &str, path: &str) -> Result<GetOutput, String> {
get_at(
&locate_unconfined(file)?,
file,
path,
&noyalib::ParserConfig::default(),
)
}
fn locate_unconfined(file: &str) -> Result<Located, String> {
Located::unconfined(Path::new(file)).map_err(|e| format!("read {file}: {e}"))
}
fn read_at(
at: &Located,
file: &str,
config: &noyalib::ParserConfig,
) -> Result<(String, Kept), String> {
at.read(config.max_document_length)
.map_err(|e| format!("read {file}: {e}"))
}
pub(crate) fn get_at(
at: &Located,
file: &str,
path: &str,
config: &noyalib::ParserConfig,
) -> Result<GetOutput, String> {
let (src, _) = read_at(at, file, config)?;
let doc = parse_document_with_config(&src, config).map_err(|e| format!("parse {file}: {e}"))?;
match doc.get(path) {
Some(value) => Ok(GetOutput {
value: value.to_string(),
}),
None if doc.key_span(path).is_some() => Ok(GetOutput {
value: String::new(),
}),
None => Err(format!("path not found in {}: {}", clip(file), clip(path))),
}
}
pub fn set(file: &str, path: &str, value: &str) -> Result<SetOutput, String> {
set_at(
&locate_unconfined(file)?,
file,
path,
value,
&noyalib::ParserConfig::default(),
)
}
pub(crate) fn set_at(
at: &Located,
file: &str,
path: &str,
value: &str,
config: &noyalib::ParserConfig,
) -> Result<SetOutput, String> {
let (src, kept) = read_at(at, file, config)?;
check_fragment(value, config)?;
let mut doc =
parse_document_with_config(&src, config).map_err(|e| format!("parse {file}: {e}"))?;
doc.set(path, value)
.map_err(|e| set_failed(path, value, &e))?;
at.replace(doc.to_string().as_bytes(), kept)
.map_err(|e| format!("write {file}: {e}"))?;
Ok(SetOutput {
file: file.to_owned(),
path: path.to_owned(),
value: value.to_owned(),
})
}
pub fn set_multidoc(
file: &str,
doc_index: usize,
path: &str,
value: &str,
) -> Result<SetMultidocOutput, String> {
let at = locate_unconfined(file)?;
let config = noyalib::ParserConfig::default();
set_multidoc_at(&at, file, doc_index, path, value, &config)
}
pub(crate) fn set_multidoc_at(
at: &Located,
file: &str,
doc_index: usize,
path: &str,
value: &str,
config: &noyalib::ParserConfig,
) -> Result<SetMultidocOutput, String> {
let (src, kept) = read_at(at, file, config)?;
let mut docs =
parse_stream_with_config(&src, config).map_err(|e| format!("parse {file}: {e}"))?;
if doc_index >= docs.len() {
return Err(format!(
"doc_index {doc_index} out of range: stream has {} document(s)",
docs.len()
));
}
check_fragment(value, config)?;
docs[doc_index]
.set(path, value)
.map_err(|e| set_failed(path, value, &e))?;
let out: String = docs.iter().map(ToString::to_string).collect();
at.replace(out.as_bytes(), kept)
.map_err(|e| format!("write {file}: {e}"))?;
Ok(SetMultidocOutput {
file: file.to_owned(),
doc_index,
path: path.to_owned(),
value: value.to_owned(),
})
}
pub fn parse(yaml: &str) -> Result<ParseOutput, String> {
parse_with_profile(yaml, ParseProfile::default())
}
pub fn parse_with_profile(yaml: &str, profile: ParseProfile) -> Result<ParseOutput, String> {
check_text(yaml, &profile.config())?;
let docs: Vec<noyalib::Value> = noyalib::load_all_with_config(yaml, &profile.config())
.and_then(Iterator::collect)
.map_err(|e| format!("parse: {e}"))?;
let documents = docs
.into_iter()
.map(|d| serde_json::to_value(d.untag()).map_err(internal))
.collect::<Result<_, _>>()?;
Ok(ParseOutput { documents })
}
pub fn edit(yaml: &str, path: &str, value: &str) -> Result<EditOutput, String> {
edit_with_profile(yaml, path, value, ParseProfile::Standard)
}
pub fn edit_with_profile(
yaml: &str,
path: &str,
value: &str,
profile: ParseProfile,
) -> Result<EditOutput, String> {
let config = profile.config();
check_text(yaml, &config)?;
check_fragment(value, &config)?;
let mut doc = parse_document_with_config(yaml, &config).map_err(|e| format!("parse: {e}"))?;
doc.set(path, value)
.map_err(|e| set_failed(path, value, &e))?;
Ok(EditOutput {
yaml: doc.to_string(),
})
}
mod limits;
pub use limits::MAX_FRAGMENT_BYTES;
#[cfg(test)]
use limits::nesting_depth;
use limits::{check_fragment, check_text, clip, set_failed};
pub fn validate(yaml: &str, schema: Option<&str>) -> Result<ValidateOutput, String> {
validate_with_profile(yaml, schema, ParseProfile::default())
}
pub fn validate_with_profile(
yaml: &str,
schema: Option<&str>,
profile: ParseProfile,
) -> Result<ValidateOutput, String> {
let value = match parse_for_validation(yaml, &profile.config()) {
Ok(v) => v,
Err(verdict) => return Ok(verdict),
};
let violations = match schema {
Some(schema_text) => schema_violations(&value, schema_text)?,
None => Vec::new(),
};
Ok(ValidateOutput {
valid: violations.is_empty(),
error: None,
line: None,
column: None,
violations,
})
}
fn parse_for_validation(
yaml: &str,
config: &noyalib::ParserConfig,
) -> Result<noyalib::Value, ValidateOutput> {
let failed = |error: String, line, column| ValidateOutput {
valid: false,
error: Some(error),
line: Some(line),
column: Some(column),
violations: Vec::new(),
};
check_text(yaml, config).map_err(|e| failed(e, 0, 0))?;
noyalib::from_str_with_config::<noyalib::Value>(yaml, config).map_err(|e| {
let (line, column) = e.location().map_or((0, 0), |l| (l.line(), l.column()));
failed(e.to_string(), line, column)
})
}
pub const MAX_SCHEMA_BYTES: usize = 64 * 1024;
pub const MAX_VIOLATIONS: usize = 100;
fn compile_schema(schema_text: &str) -> Result<noyalib::CompiledSchema, String> {
if schema_text.len() > MAX_SCHEMA_BYTES {
return Err(format!(
"the schema is {} bytes, over the {MAX_SCHEMA_BYTES}-byte schema limit",
schema_text.len()
));
}
let schema: JsonValue =
serde_json::from_str(schema_text).map_err(|e| format!("schema is not JSON: {e}"))?;
let schema_value: noyalib::Value =
serde_json::from_value(schema).map_err(|e| format!("schema: {e}"))?;
noyalib::CompiledSchema::builder(&schema_value)
.max_errors(MAX_VIOLATIONS + 1)
.build()
.map_err(|e| format!("schema: {e}"))
}
fn schema_violations(value: &noyalib::Value, schema_text: &str) -> Result<Vec<Violation>, String> {
let compiled = compile_schema(schema_text)?;
let all = compiled.iter_errors(value).map_err(internal)?;
let mut violations: Vec<Violation> = all
.iter()
.take(MAX_VIOLATIONS)
.map(|v| Violation {
path: clip(&v.instance_path).into_owned(),
keyword: v.keyword.clone(),
message: clip(&v.message).into_owned(),
})
.collect();
if all.len() > MAX_VIOLATIONS {
violations.push(Violation {
path: String::new(),
keyword: "truncated".to_owned(),
message: format!(
"more than {MAX_VIOLATIONS} violations; the first {MAX_VIOLATIONS} are listed"
),
});
}
Ok(violations)
}
fn internal(e: impl fmt::Display) -> String {
format!("internal: {e}")
}
pub(crate) fn locate(root: &RootDir, file: &str) -> Result<Located, String> {
root.locate(Path::new(file)).map_err(|e| match e {
FileError::Outside => outside(file),
FileError::Io(e) => format!("read {file}: {e}"),
})
}
pub(crate) fn outside(file: &str) -> String {
format!("{file} is outside the server root; start noyalib-mcp with --root to allow it")
}
pub const DEFAULT_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
async fn off_thread<T, F>(limit: std::time::Duration, work: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> Result<T, String> + Send + 'static,
{
match tokio::time::timeout(limit, tokio::task::spawn_blocking(work)).await {
Ok(joined) => joined.unwrap_or_else(|e| Err(internal(e))),
Err(_) => Err(format!(
"the call ran past the {} ms time limit and was abandoned",
limit.as_millis()
)),
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetArgs {
#[schemars(example = &"config.yaml")]
pub file: String,
#[schemars(example = &"server.host")]
pub path: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetArgs {
#[schemars(example = &"config.yaml")]
pub file: String,
#[schemars(example = &"server.port")]
pub path: String,
#[schemars(example = &"9090")]
pub value: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetMultidocArgs {
#[schemars(example = &"manifests.yaml")]
pub file: String,
#[schemars(example = &0, range(min = 0))]
pub doc_index: usize,
#[schemars(example = &"metadata.name")]
pub path: String,
#[schemars(example = &"api")]
pub value: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ParseArgs {
#[schemars(example = &"server:\n host: api.example.com # public\n port: 8080\n")]
pub yaml: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct EditArgs {
#[schemars(example = &"server:\n host: api.example.com # public\n port: 8080\n")]
pub yaml: String,
#[schemars(example = &"server.port")]
pub path: String,
#[schemars(example = &"9090")]
pub value: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ValidateArgs {
#[schemars(example = &"server:\n host: api.example.com # public\n port: 8080\n")]
pub yaml: String,
#[schemars(example = &"{\"type\":\"object\",\"required\":[\"server\"]}")]
#[serde(default)]
pub schema: Option<String>,
}
fn reply<T: Serialize + fmt::Display>(
outcome: Result<T, String>,
is_error: impl FnOnce(&T) -> bool,
) -> Result<CallToolResult, ErrorData> {
match outcome {
Ok(value) => {
let structured = serde_json::to_value(&value)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let content = vec![ContentBlock::text(value.to_string())];
let mut result = if is_error(&value) {
CallToolResult::error(content)
} else {
CallToolResult::success(content)
};
result.structured_content = Some(structured);
Ok(result)
}
Err(message) => Ok(CallToolResult::error(vec![ContentBlock::text(message)])),
}
}
pub const TOOL_NAMES: [&str; 6] = [
"noyalib_get",
"noyalib_set",
"noyalib_set_multidoc",
"noyalib_parse",
"noyalib_edit",
"noyalib_validate",
];
#[tool_router(vis = "pub(crate)")]
#[allow(
clippy::unused_self,
reason = "the SDK's tool router calls tools as methods"
)]
impl YamlServer {
#[tool(
name = "noyalib_get",
title = "Read a YAML value (lossless)",
description = "Read the YAML value at a dotted/indexed path \
in the given file and return the source slice exactly — no \
re-quoting, no canonicalisation, comments and formatting \
preserved. Use this to inspect a value before changing it; \
use `noyalib_set` to write a value back losslessly.",
// Reads a caller-supplied YAML file without modifying it:
// read-only, idempotent, never destructive, and open-world (it
// touches the local filesystem).
annotations(
title = "Read a YAML value (lossless)",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true
),
output_schema = schema_for_output::<GetOutput>()
)]
async fn noyalib_get(
&self,
Parameters(args): Parameters<GetArgs>,
) -> Result<CallToolResult, ErrorData> {
let (root, config) = (self.root.clone(), self.profile.config());
let outcome = off_thread(self.call_timeout, move || {
let at = locate(&root, &args.file)?;
get_at(&at, &args.file, &args.path, &config)
});
reply(outcome.await, |_| false)
}
#[tool(
name = "noyalib_set",
title = "Write a YAML value (lossless)",
description = "Set the YAML value at a dotted/indexed path in \
the given file, rewriting only the touched span so every \
comment, blank line, and sibling entry is preserved \
byte-for-byte (written atomically). Use this for \
Renovate-style version bumps and config patches; use \
`noyalib_get` first when you need to read the current \
value. On a parse error the document is left unchanged.",
// Overwrites the value at a path in a caller-supplied file on
// disk: not read-only, and destructive (it replaces existing
// content in place). Re-running with the same arguments yields
// the same file state, so it is idempotent; it touches the
// filesystem, so it is open-world.
annotations(
title = "Write a YAML value (lossless)",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = true
),
output_schema = schema_for_output::<SetOutput>()
)]
async fn noyalib_set(
&self,
Parameters(args): Parameters<SetArgs>,
) -> Result<CallToolResult, ErrorData> {
let (root, config) = (self.root.clone(), self.profile.config());
let outcome = off_thread(self.call_timeout, move || {
let at = locate(&root, &args.file)?;
set_at(&at, &args.file, &args.path, &args.value, &config)
});
reply(outcome.await, |_| false)
}
#[tool(
name = "noyalib_set_multidoc",
title = "Write a YAML value in one document of a multi-doc stream (lossless)",
description = "Set the YAML value at a dotted/indexed path within \
a single document of a multi-document (`---`-separated) YAML \
stream, selected by zero-based document index. Only the touched \
span of that one document is rewritten; every other document, \
comment, blank line and separator is preserved byte-for-byte \
(written atomically). Use `noyalib_set` for a single-document \
file. On a parse error or out-of-range index the file is left \
unchanged.",
annotations(
title = "Write a YAML value in one document of a multi-doc stream (lossless)",
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = true
),
output_schema = schema_for_output::<SetMultidocOutput>()
)]
async fn noyalib_set_multidoc(
&self,
Parameters(args): Parameters<SetMultidocArgs>,
) -> Result<CallToolResult, ErrorData> {
let (root, config) = (self.root.clone(), self.profile.config());
let outcome = off_thread(self.call_timeout, move || {
let at = locate(&root, &args.file)?;
let SetMultidocArgs {
file,
doc_index,
path,
value,
} = &args;
set_multidoc_at(&at, file, *doc_index, path, value, &config)
});
reply(outcome.await, |_| false)
}
#[tool(
name = "noyalib_parse",
title = "Parse YAML text into JSON (stateless)",
description = "Parse the YAML text given in the request and return \
its JSON data model (custom tags stripped, the projection the \
official YAML test suite expects). Multi-document streams return \
a JSON array with one element per document. Refuses hostile \
input (nesting, alias expansion, size) and duplicate keys under \
the strict YAML 1.2 profile, the same rules as \
the library. Nothing is read from or written to disk.",
// Content-in-request: nothing on disk is read or written. Pure,
// read-only, idempotent, closed-world.
annotations(
title = "Parse YAML text into JSON (stateless)",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
),
output_schema = schema_for_output::<ParseOutput>()
)]
async fn noyalib_parse(
&self,
Parameters(args): Parameters<ParseArgs>,
) -> Result<CallToolResult, ErrorData> {
let profile = self.profile();
let outcome = off_thread(self.call_timeout, move || {
parse_with_profile(&args.yaml, profile)
});
reply(outcome.await, |_| false)
}
#[tool(
name = "noyalib_edit",
title = "Edit a value in YAML text, losslessly (stateless)",
description = "Set the value at a dotted/indexed path in the YAML text \
given in the request and return the whole edited text. Only the \
touched span changes; every comment, blank line and quote style \
elsewhere is preserved byte-for-byte. Nothing on disk is touched: \
the caller decides where the result goes.",
annotations(
title = "Edit a value in YAML text, losslessly (stateless)",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
),
output_schema = schema_for_output::<EditOutput>()
)]
async fn noyalib_edit(
&self,
Parameters(args): Parameters<EditArgs>,
) -> Result<CallToolResult, ErrorData> {
let profile = self.profile();
let outcome = off_thread(self.call_timeout, move || {
edit_with_profile(&args.yaml, &args.path, &args.value, profile)
});
reply(outcome.await, |_| false)
}
#[tool(
name = "noyalib_validate",
title = "Validate YAML text, optionally against a JSON Schema (stateless)",
description = "Check that the YAML text parses under the library's \
limits, and when a JSON Schema (as JSON text) is given, that the \
document satisfies it. Returns `valid` with an empty list, or the \
parse error with its line and column, or every schema violation \
with its RFC 6901 path. Nothing on disk is touched.",
annotations(
title = "Validate YAML text, optionally against a JSON Schema (stateless)",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
),
output_schema = schema_for_output::<ValidateOutput>()
)]
async fn noyalib_validate(
&self,
Parameters(args): Parameters<ValidateArgs>,
) -> Result<CallToolResult, ErrorData> {
let profile = self.profile();
let outcome = off_thread(self.call_timeout, move || {
validate_with_profile(&args.yaml, args.schema.as_deref(), profile)
});
reply(outcome.await, |v| !v.valid)
}
}
#[cfg(test)]
mod tests;