#![forbid(unsafe_code)]
#![warn(missing_docs)]
use rmcp::handler::server::router::prompt::PromptRouter;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::tool::ToolCallContext;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData,
Implementation, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams,
ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, ServerCapabilities,
ServerConfig,
};
use rmcp::service::RequestContext;
use rmcp::{RoleServer, ServerHandler, prompt_handler, tool_handler};
use std::path::{Path, PathBuf};
mod fsio;
pub mod prompts;
pub mod resources;
pub mod tools;
pub use tools::{
DEFAULT_CALL_TIMEOUT, EditArgs, EditOutput, GetArgs, GetOutput, MAX_FRAGMENT_BYTES,
MAX_SCHEMA_BYTES, MAX_VIOLATIONS, ParseArgs, ParseOutput, ParseProfile, SetArgs,
SetMultidocArgs, SetMultidocOutput, SetOutput, TOOL_NAMES, ValidateArgs, ValidateOutput,
Violation, edit, edit_with_profile, get, parse, parse_with_profile, set, set_multidoc,
validate, validate_with_profile,
};
const CACHE_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Read and edit YAML files losslessly. \
noyalib_get reads the value at a dotted/indexed path in a file; \
noyalib_set and noyalib_set_multidoc rewrite one value while \
preserving every comment and all formatting. noyalib_parse, \
noyalib_edit and noyalib_validate do the same for YAML text given \
in the request and touch nothing on disk.";
#[derive(Debug, Clone)]
pub struct YamlServer {
tool_router: ToolRouter<Self>,
prompt_router: PromptRouter<Self>,
root: fsio::RootDir,
profile: ParseProfile,
call_timeout: std::time::Duration,
}
impl Default for YamlServer {
fn default() -> Self {
Self::new()
}
}
impl YamlServer {
#[must_use]
pub fn new() -> Self {
let cwd = std::env::current_dir().unwrap_or(PathBuf::from("."));
Self::with_root(cwd)
}
#[must_use]
pub fn with_root(root: PathBuf) -> Self {
Self {
tool_router: Self::tool_router(),
prompt_router: Self::prompt_router(),
root: fsio::RootDir::open(root),
profile: ParseProfile::default(),
call_timeout: tools::DEFAULT_CALL_TIMEOUT,
}
}
#[must_use]
pub fn with_profile(mut self, profile: ParseProfile) -> Self {
self.profile = profile;
self
}
#[must_use]
pub fn with_call_timeout(mut self, limit: std::time::Duration) -> Self {
self.call_timeout = limit;
self
}
#[must_use]
pub fn profile(&self) -> ParseProfile {
self.profile
}
#[must_use]
pub fn root(&self) -> &Path {
self.root.path()
}
pub fn confine(&self, file: &str) -> Result<PathBuf, String> {
drop(tools::locate(&self.root, file)?);
let candidate = self.root().join(file);
let resolved = candidate
.canonicalize()
.map_err(|e| format!("read {file}: {e}"))?;
if resolved.starts_with(self.root()) {
Ok(resolved)
} else {
Err(tools::outside(file))
}
}
}
#[tool_handler(router = self.tool_router)]
#[prompt_handler(router = self.prompt_router)]
impl ServerHandler for YamlServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(
ServerCapabilities::builder()
.enable_tools()
.enable_prompts()
.enable_resources()
.build(),
)
.with_server_info(
Implementation::new("noyalib-mcp", env!("CARGO_PKG_VERSION"))
.with_title("Noyalib MCP")
.with_website_url(env!("CARGO_PKG_REPOSITORY")),
)
.with_instructions(INSTRUCTIONS)
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
if !self.tool_router.has_route(&request.name) {
return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"Unknown tool: {}. The tools are {}.",
request.name,
TOOL_NAMES.join(", ")
))])
.into());
}
let call = ToolCallContext::new(self, request, context);
self.tool_router.call(call).await
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
Ok(ListResourcesResult::with_all_items(resources::list()).with_ttl_ms(CACHE_TTL_MS))
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> {
Ok(
ListResourceTemplatesResult::with_all_items(resources::templates())
.with_ttl_ms(CACHE_TTL_MS),
)
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, ErrorData> {
let contents = resources::read(&request.uri, &self.tool_router.list_all())?;
Ok(ReadResourceResult::new(vec![contents])
.with_ttl_ms(CACHE_TTL_MS)
.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_server_describes_itself() {
let info = YamlServer::default().get_info();
assert_eq!(info.server_info.name, "noyalib-mcp");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
assert!(info.capabilities.tools.is_some());
assert!(info.capabilities.prompts.is_some());
assert!(info.capabilities.resources.is_some());
assert!(
info.instructions
.as_deref()
.is_some_and(|i| i.contains("noyalib_get") && i.contains("noyalib_validate"))
);
}
#[test]
fn the_routers_agree_with_the_name_lists() {
let s = YamlServer::new();
for name in TOOL_NAMES {
assert!(s.tool_router.has_route(name), "{name}");
}
assert!(!s.tool_router.has_route("frobnicate"));
for name in prompts::PROMPT_NAMES {
assert!(s.prompt_router.has_route(name), "{name}");
}
}
#[test]
fn the_default_server_is_rooted_at_the_working_directory_and_strict() {
let s = YamlServer::default();
let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
assert_eq!(s.root(), cwd.as_path());
assert_eq!(s.profile(), ParseProfile::Strict);
let standard = s.with_profile(ParseProfile::Standard);
assert_eq!(standard.profile(), ParseProfile::Standard);
}
#[test]
fn confine_reports_a_missing_file_and_accepts_one_inside() {
let root = std::env::temp_dir().join(format!("noyalib-mcp-confine-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("in.yml"), "a: 1\n").unwrap();
let s = YamlServer::with_root(root.clone());
assert!(
s.confine("missing.yml")
.is_err_and(|e| e.contains("missing.yml"))
);
let inside = s.confine("in.yml").unwrap();
assert!(inside.starts_with(s.root()));
let _ = std::fs::remove_dir_all(root);
}
}