#![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 tools;
#[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,
}
#[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();
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: value,
id,
})
.expect("infallible serialise"),
),
(Some(id), Err((code, msg))) => HandleOutcome::Reply(error_str(id, code, msg)),
}
}
pub fn dispatch(method: &str, params: JsonValue) -> Result<JsonValue, (i32, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": "2025-06-18",
"serverInfo": {
"name": "noyalib-mcp",
"version": env!("CARGO_PKG_VERSION"),
},
"capabilities": {
"tools": {}
}
})),
"initialized" | "notifications/initialized" => Ok(JsonValue::Null),
"tools/list" => Ok(json!({
"tools": tools::descriptors()
})),
"tools/call" => tools::call(params),
"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 },
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());
}
#[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());
}
}