#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![cfg_attr(noyalib_coverage, allow(unstable_features))]
#![cfg_attr(noyalib_coverage, feature(coverage_attribute))]
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
pub mod prompts;
pub mod resources;
pub mod tools;
pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] = ["2026-07-28", "2025-06-18"];
pub const LEGACY_PROTOCOL_VERSION: &str = "2025-06-18";
pub const META_PROTOCOL_VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
const META_SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
pub const UNSUPPORTED_PROTOCOL_VERSION: i32 = -32022;
const CACHE_TTL_MS: u64 = 3_600_000;
#[derive(Debug, Deserialize)]
pub struct Request {
pub jsonrpc: String,
pub method: String,
#[serde(default)]
pub params: JsonValue,
pub id: Option<JsonValue>,
}
#[derive(Debug, Serialize)]
pub struct Response {
pub jsonrpc: &'static str,
pub result: JsonValue,
pub id: JsonValue,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub jsonrpc: &'static str,
pub error: ErrorObject,
pub id: JsonValue,
}
#[derive(Debug, Serialize)]
pub struct ErrorObject {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<JsonValue>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum HandleOutcome {
Reply(String),
Silent,
}
#[must_use]
pub fn handle_message(raw: &str) -> HandleOutcome {
let req: Request = match serde_json::from_str(raw) {
Ok(r) => r,
Err(e) => {
return HandleOutcome::Reply(error_str(
JsonValue::Null,
-32700,
format!("parse error: {e}"),
));
}
};
if req.jsonrpc != "2.0" {
return HandleOutcome::Reply(error_str(
req.id.unwrap_or(JsonValue::Null),
-32600,
"invalid request: jsonrpc must be \"2.0\"".to_string(),
));
}
let id = req.id.clone();
if let Some(requested) = req
.params
.get("_meta")
.and_then(|m| m.get(META_PROTOCOL_VERSION_KEY))
.and_then(JsonValue::as_str)
{
if !SUPPORTED_PROTOCOL_VERSIONS.contains(&requested) {
return match id {
None => HandleOutcome::Silent,
Some(id) => HandleOutcome::Reply(
serde_json::to_string(&ErrorResponse {
jsonrpc: "2.0",
error: ErrorObject {
code: UNSUPPORTED_PROTOCOL_VERSION,
message: "Unsupported protocol version".to_string(),
data: Some(json!({
"supported": SUPPORTED_PROTOCOL_VERSIONS,
"requested": requested,
})),
},
id,
})
.expect("infallible serialise"),
),
};
}
}
let result = dispatch(&req.method, req.params);
match (id, result) {
(None, _) => HandleOutcome::Silent,
(Some(id), Ok(value)) => HandleOutcome::Reply(
serde_json::to_string(&Response {
jsonrpc: "2.0",
result: decorate_result(value),
id,
})
.expect("infallible serialise"),
),
(Some(id), Err((code, msg))) => HandleOutcome::Reply(error_str(id, code, msg)),
}
}
fn decorate_result(mut value: JsonValue) -> JsonValue {
if let JsonValue::Object(map) = &mut value {
let _ = map.entry("resultType").or_insert_with(|| json!("complete"));
let meta = map.entry("_meta").or_insert_with(|| json!({}));
if let Some(meta) = meta.as_object_mut() {
let _ = meta.entry(META_SERVER_INFO_KEY).or_insert_with(|| {
json!({
"name": "noyalib-mcp",
"version": env!("CARGO_PKG_VERSION"),
})
});
}
}
value
}
pub fn dispatch(method: &str, params: JsonValue) -> Result<JsonValue, (i32, String)> {
match method {
"initialize" => {
let requested = params.get("protocolVersion").and_then(JsonValue::as_str);
let negotiated = match requested {
Some(v) if SUPPORTED_PROTOCOL_VERSIONS.contains(&v) => v,
_ => LEGACY_PROTOCOL_VERSION,
};
Ok(json!({
"protocolVersion": negotiated,
"serverInfo": {
"name": "noyalib-mcp",
"version": env!("CARGO_PKG_VERSION"),
},
"capabilities": {
"tools": {},
"prompts": {},
"resources": {}
}
}))
}
"initialized" | "notifications/initialized" => Ok(JsonValue::Null),
"server/discover" => Ok(json!({
"supportedVersions": SUPPORTED_PROTOCOL_VERSIONS,
"capabilities": {
"tools": {},
"prompts": {},
"resources": {}
},
"instructions": "Read and edit YAML files losslessly: \
noyalib_get reads the value at a path, \
noyalib_set / noyalib_set_multidoc \
rewrite one value while preserving all \
comments and formatting.",
"ttlMs": CACHE_TTL_MS,
"cacheScope": "public",
})),
"tools/list" => Ok(json!({
"tools": tools::descriptors(),
"ttlMs": CACHE_TTL_MS,
"cacheScope": "public",
})),
"tools/call" => tools::call(params),
"prompts/list" => Ok(json!({
"prompts": prompts::descriptors(),
"ttlMs": CACHE_TTL_MS,
"cacheScope": "public",
})),
"prompts/get" => prompts::get(params),
"resources/list" => Ok(json!({
"resources": resources::descriptors(),
"ttlMs": CACHE_TTL_MS,
"cacheScope": "public",
})),
"resources/templates/list" => Ok(json!({
"resourceTemplates": resources::templates(),
"ttlMs": CACHE_TTL_MS,
"cacheScope": "public",
})),
"resources/read" => resources::read(params).map(|mut v| {
if let JsonValue::Object(map) = &mut v {
let _ = map.entry("ttlMs").or_insert_with(|| json!(CACHE_TTL_MS));
let _ = map.entry("cacheScope").or_insert_with(|| json!("public"));
}
v
}),
"ping" => Ok(JsonValue::Object(serde_json::Map::new())),
other => Err((-32601, format!("method not found: {other}"))),
}
}
pub fn error_str(id: JsonValue, code: i32, message: String) -> String {
serde_json::to_string(&ErrorResponse {
jsonrpc: "2.0",
error: ErrorObject {
code,
message,
data: None,
},
id,
})
.expect("infallible serialise")
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_reply(out: HandleOutcome) -> JsonValue {
match out {
HandleOutcome::Reply(s) => serde_json::from_str(&s).unwrap(),
HandleOutcome::Silent => panic!("expected Reply, got Silent"),
}
}
#[test]
fn handle_message_returns_parse_error_on_bad_json() {
let out = handle_message("not json {");
let v = parse_reply(out);
assert_eq!(v["error"]["code"].as_i64().unwrap(), -32700);
assert!(
v["error"]["message"]
.as_str()
.unwrap()
.contains("parse error")
);
assert!(v["id"].is_null());
}
#[test]
fn handle_message_rejects_non_2_0_jsonrpc() {
let req = json!({"jsonrpc": "1.0", "method": "ping", "id": 1});
let out = handle_message(&req.to_string());
let v = parse_reply(out);
assert_eq!(v["error"]["code"].as_i64().unwrap(), -32600);
assert_eq!(v["id"].as_i64().unwrap(), 1);
}
#[test]
fn handle_message_returns_silent_for_notifications() {
let req = json!({"jsonrpc": "2.0", "method": "ping"});
let out = handle_message(&req.to_string());
assert_eq!(out, HandleOutcome::Silent);
}
#[test]
fn handle_message_returns_silent_for_notifications_initialized() {
let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
let out = handle_message(&req.to_string());
assert_eq!(out, HandleOutcome::Silent);
}
#[test]
fn handle_message_returns_unknown_method_error() {
let req = json!({"jsonrpc": "2.0", "method": "frobnicate", "id": 7});
let out = handle_message(&req.to_string());
let v = parse_reply(out);
assert_eq!(v["error"]["code"].as_i64().unwrap(), -32601);
assert!(
v["error"]["message"]
.as_str()
.unwrap()
.contains("frobnicate")
);
assert_eq!(v["id"].as_i64().unwrap(), 7);
}
#[test]
fn handle_message_returns_jsonrpc_error_when_jsonrpc_field_missing() {
let req = json!({"method": "ping", "id": 1});
let out = handle_message(&req.to_string());
let v = parse_reply(out);
assert!(v["error"].is_object());
}
#[test]
fn dispatch_initialize_returns_protocol_metadata() {
let v = dispatch("initialize", JsonValue::Null).unwrap();
assert_eq!(v["protocolVersion"].as_str().unwrap(), "2025-06-18");
assert_eq!(v["serverInfo"]["name"].as_str().unwrap(), "noyalib-mcp");
assert!(v["capabilities"]["tools"].is_object());
assert!(v["capabilities"]["prompts"].is_object());
assert!(v["capabilities"]["resources"].is_object());
}
#[test]
fn initialize_echoes_a_supported_requested_version() {
for v in SUPPORTED_PROTOCOL_VERSIONS {
let r = dispatch("initialize", json!({"protocolVersion": v})).unwrap();
assert_eq!(r["protocolVersion"].as_str().unwrap(), v, "requested {v}");
}
}
#[test]
fn initialize_answers_legacy_for_an_unknown_version() {
let r = dispatch("initialize", json!({"protocolVersion": "2024-11-05"})).unwrap();
assert_eq!(
r["protocolVersion"].as_str().unwrap(),
LEGACY_PROTOCOL_VERSION
);
}
#[test]
fn server_discover_lists_versions_and_capabilities() {
let v = dispatch("server/discover", JsonValue::Null).unwrap();
let versions: Vec<&str> = v["supportedVersions"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap())
.collect();
assert_eq!(versions, SUPPORTED_PROTOCOL_VERSIONS);
assert!(v["capabilities"]["tools"].is_object());
assert!(v["ttlMs"].is_u64());
assert_eq!(v["cacheScope"].as_str().unwrap(), "public");
}
#[test]
fn results_carry_the_modern_envelope_fields() {
let req = json!({"jsonrpc": "2.0", "method": "tools/list", "id": 7});
let v = parse_reply(handle_message(&req.to_string()));
assert_eq!(v["result"]["resultType"].as_str().unwrap(), "complete");
assert_eq!(
v["result"]["_meta"][META_SERVER_INFO_KEY]["name"]
.as_str()
.unwrap(),
"noyalib-mcp"
);
assert!(v["result"]["ttlMs"].is_u64());
assert_eq!(v["result"]["cacheScope"].as_str().unwrap(), "public");
}
#[test]
fn a_supported_meta_version_is_served() {
let req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"id": 8,
"params": {"_meta": {META_PROTOCOL_VERSION_KEY: "2026-07-28"}},
});
let v = parse_reply(handle_message(&req.to_string()));
assert!(v["result"]["tools"].is_array());
}
#[test]
fn an_unsupported_meta_version_is_refused_with_the_supported_list() {
let req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"id": 9,
"params": {"_meta": {META_PROTOCOL_VERSION_KEY: "1900-01-01"}},
});
let v = parse_reply(handle_message(&req.to_string()));
assert_eq!(
v["error"]["code"].as_i64().unwrap(),
i64::from(UNSUPPORTED_PROTOCOL_VERSION)
);
assert_eq!(v["error"]["data"]["requested"], "1900-01-01");
let supported = v["error"]["data"]["supported"].as_array().unwrap();
assert_eq!(supported.len(), SUPPORTED_PROTOCOL_VERSIONS.len());
}
#[test]
fn resources_read_is_cacheable() {
let v = dispatch("resources/read", json!({"uri": "noyalib://tools"})).unwrap();
assert!(v["ttlMs"].is_u64());
assert_eq!(v["cacheScope"].as_str().unwrap(), "public");
}
#[test]
fn dispatch_prompts_list_returns_prompt_array() {
let v = dispatch("prompts/list", JsonValue::Null).unwrap();
let prompts = v["prompts"].as_array().unwrap();
assert!(prompts.iter().any(|p| p["name"] == "format_and_lint_yaml"));
}
#[test]
fn dispatch_prompts_get_returns_messages() {
let v = dispatch("prompts/get", json!({"name": "format_and_lint_yaml"})).unwrap();
assert!(v["messages"].as_array().unwrap().len() == 1);
}
#[test]
fn dispatch_resources_list_returns_resource_array() {
let v = dispatch("resources/list", JsonValue::Null).unwrap();
let resources = v["resources"].as_array().unwrap();
assert!(resources.iter().any(|r| r["uri"] == "noyalib://tools"));
}
#[test]
fn dispatch_resources_templates_list_returns_templates() {
let v = dispatch("resources/templates/list", JsonValue::Null).unwrap();
let templates = v["resourceTemplates"].as_array().unwrap();
assert!(
templates
.iter()
.any(|t| t["uriTemplate"] == "noyalib://tool/{name}")
);
}
#[test]
fn dispatch_resources_read_returns_contents() {
let v = dispatch("resources/read", json!({"uri": "noyalib://error-codes"})).unwrap();
assert!(v["contents"].as_array().unwrap().len() == 1);
}
#[test]
fn dispatch_initialized_returns_null() {
let v = dispatch("initialized", JsonValue::Null).unwrap();
assert!(v.is_null());
}
#[test]
fn dispatch_notifications_initialized_returns_null() {
let v = dispatch("notifications/initialized", JsonValue::Null).unwrap();
assert!(v.is_null());
}
#[test]
fn dispatch_tools_list_returns_descriptor_array() {
let v = dispatch("tools/list", JsonValue::Null).unwrap();
let tools = v["tools"].as_array().unwrap();
assert!(tools.iter().any(|t| t["name"] == "noyalib_get"));
assert!(tools.iter().any(|t| t["name"] == "noyalib_set"));
}
#[test]
fn dispatch_ping_returns_empty_object() {
let v = dispatch("ping", JsonValue::Null).unwrap();
assert!(v.is_object());
assert!(v.as_object().unwrap().is_empty());
}
#[test]
fn dispatch_unknown_method_returns_method_not_found() {
let err = dispatch("frobnicate", JsonValue::Null).unwrap_err();
assert_eq!(err.0, -32601);
assert!(err.1.contains("frobnicate"));
}
#[test]
fn dispatch_tools_call_propagates_tools_errors() {
let err = dispatch("tools/call", json!({})).unwrap_err();
assert_eq!(err.0, -32602);
}
#[test]
fn error_str_renders_canonical_envelope() {
let s = error_str(json!(42), -32000, "boom".into());
let v: JsonValue = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"].as_str().unwrap(), "2.0");
assert_eq!(v["id"].as_i64().unwrap(), 42);
assert_eq!(v["error"]["code"].as_i64().unwrap(), -32000);
assert_eq!(v["error"]["message"].as_str().unwrap(), "boom");
}
#[test]
fn error_str_handles_null_id() {
let s = error_str(JsonValue::Null, -32700, "parse".into());
let v: JsonValue = serde_json::from_str(&s).unwrap();
assert!(v["id"].is_null());
}
}