noyalib_mcp/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Library surface for `noyalib-mcp`.
5//!
6//! Hosts the JSON-RPC 2.0 dispatch logic and the tool implementations.
7//! The `noyalib-mcp` binary in `main.rs` is a thin stdio loop that
8//! drives [`handle_message`]; tests reach the same handlers
9//! directly so coverage no longer depends on standing up a real
10//! stdio process.
11//!
12//! # Cargo features
13//!
14//! This crate exposes no optional features; the MCP tool set
15//! (`noyalib_get`, `noyalib_set`) is fixed. Optional `noyalib`
16//! features pulled in by a downstream packager (`schema`,
17//! `parallel`, …) do not change the MCP wire surface — they
18//! only affect what `noyalib::Error` messages can appear inside
19//! tool-call error envelopes. The canonical `noyalib` feature
20//! matrix lives in
21//! [`crates/noyalib/src/lib.rs`](https://docs.rs/noyalib).
22//!
23//! # MSRV
24//!
25//! **Rust 1.75.0** stable — same as the core `noyalib` library.
26//! The MCP wire surface is text-only JSON-RPC and pulls no
27//! nightly-only deps. CI verifies the floor via the
28//! `Per-crate MSRV` workflow job. See the workspace
29//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#1-msrv-minimum-supported-rust-version)
30//! for the bump policy.
31//!
32//! # Panics
33//!
34//! Public functions in this crate do not panic on well-formed
35//! input. The MCP binary `unwrap`s once on stdin acquisition
36//! during boot — that's deliberate, every caller invokes the
37//! binary via a host process that controls the pipe.
38//!
39//! # Errors
40//!
41//! Tool calls return JSON-RPC error envelopes per the
42//! [MCP specification](https://modelcontextprotocol.io). The
43//! error code taxonomy lives in
44//! [`crates/noyalib-mcp/doc/tools-reference.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-mcp/doc/tools-reference.md):
45//! `-32000` (file I/O), `-32001` (parse), `-32002` (path not
46//! found), `-32003` (set), `-32602` (missing arg), `-32601`
47//! (unknown method).
48//!
49//! # Concurrency
50//!
51//! Each MCP request is processed sequentially on the binary's
52//! stdio loop. The host (Claude Desktop, Cursor, Zed, …) is
53//! responsible for not pipelining requests; if it does, the
54//! tool execution is serialised by the loop's `BufRead` reader.
55//!
56//! # Platform support
57//!
58//! Tier-1 (CI-verified each PR): `aarch64-apple-darwin`,
59//! `x86_64-unknown-linux-gnu`, `x86_64-pc-windows-msvc`.
60//!
61//! `noyalib_set` writes via an *atomic file replacement*
62//! helper: write to a sibling temp file → `sync_all` →
63//! `rename`. This is naturally atomic on POSIX; on Windows it
64//! uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING |
65//! MOVEFILE_WRITE_THROUGH)` semantics so concurrent readers
66//! always see either the old or the new contents — never a
67//! half-write or a stale-page-cache observation. This was the
68//! fix for the historical Windows-only `tool_call_set_preserves_comments`
69//! flake.
70//!
71//! # Performance
72//!
73//! Each `tools/call` round-trip goes through one
74//! `noyalib::cst::parse_document` (`O(n)` over input bytes)
75//! and, for `noyalib_set`, one `Document::to_string` emit
76//! (`O(n)` over output bytes). JSON-RPC line framing is
77//! amortised constant-time per message. Tool calls do **not**
78//! cache the parsed CST between requests — every call is a
79//! fresh parse so concurrent edits from outside the MCP server
80//! are always observed. Typical tool-call latency on a 100 KB
81//! YAML file: 1–3 ms parse + emit on commodity hardware.
82//!
83//! # Security
84//!
85//! `#![forbid(unsafe_code)]`. No FFI. No network I/O —
86//! `noyalib-mcp` is stdio-only by design; remote hosting goes
87//! through a separate broker (see `examples/hosted-mcp-run.md`).
88//! The server has no auth layer; restrict the working
89//! directory of the spawned process via container mounts /
90//! systemd `ReadWritePaths=` for production deployments.
91//! Resource-limit gates are inherited from `noyalib`'s
92//! `ParserConfig` defaults. Full posture:
93//! [`SECURITY.md`](https://github.com/sebastienrousseau/noyalib/blob/main/SECURITY.md).
94//!
95//! # API stability and SemVer
96//!
97//! Pre-1.0 (`0.0.x`): the MCP wire contract (tool names,
98//! input-schema shapes, error code ranges, `protocolVersion`
99//! string) is **stable** within a 0.0.x line — bug fixes only.
100//! Adding a new tool is allowed within a 0.0.x bump; removing
101//! or renaming a tool, or repurposing an error code, is held
102//! to a 0.x bump (e.g. 0.0.x → 0.1.0). The Rust library
103//! surface (`handle_message`, `dispatch`, `error_str`,
104//! `Request`, `Response`, `ErrorResponse`, `HandleOutcome`) is
105//! covered by the workspace SemVer policy in
106//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#2-semver--api-stability).
107//! `cargo-semver-checks` runs in CI on every PR.
108//!
109//! # Documentation
110//!
111//! - **Engineering policies** — workspace
112//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md).
113//! - **MCP specification**: <https://modelcontextprotocol.io>.
114//! - **Tools reference** (input schemas, error codes):
115//! [`doc/tools-reference.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-mcp/doc/tools-reference.md).
116//! - **Host configurations** (Claude Desktop, Cursor,
117//! Continue.dev, Zed, hosted gateways):
118//! [`examples/`](https://github.com/sebastienrousseau/noyalib/tree/main/crates/noyalib-mcp/examples).
119
120#![forbid(unsafe_code)]
121#![warn(missing_docs)]
122// Opt-in coverage exclusion (`NOYALIB_COVERAGE=1`) — see
123// `build.rs` for the flag, individual `coverage(off)` annotations
124// are below.
125#![cfg_attr(noyalib_coverage, allow(unstable_features))]
126#![cfg_attr(noyalib_coverage, feature(coverage_attribute))]
127
128use serde::{Deserialize, Serialize};
129use serde_json::{Value as JsonValue, json};
130
131pub mod tools;
132
133/// JSON-RPC 2.0 request envelope. Method-specific parameters live
134/// in [`JsonValue`] to keep parsing flexible across the few methods
135/// the MCP spec asks of a server.
136#[derive(Debug, Deserialize)]
137pub struct Request {
138 /// JSON-RPC version. MCP requires `"2.0"`.
139 pub jsonrpc: String,
140 /// Method name, e.g. `tools/call`. Notifications have no `id`.
141 pub method: String,
142 /// Method parameters. Shape depends on `method`.
143 #[serde(default)]
144 pub params: JsonValue,
145 /// Request id; absent on notifications.
146 pub id: Option<JsonValue>,
147}
148
149/// JSON-RPC 2.0 success response envelope.
150#[derive(Debug, Serialize)]
151pub struct Response {
152 /// Always `"2.0"`.
153 pub jsonrpc: &'static str,
154 /// The result payload.
155 pub result: JsonValue,
156 /// Echo of the corresponding request's id.
157 pub id: JsonValue,
158}
159
160/// JSON-RPC 2.0 error envelope.
161#[derive(Debug, Serialize)]
162pub struct ErrorResponse {
163 /// Always `"2.0"`.
164 pub jsonrpc: &'static str,
165 /// Error payload.
166 pub error: ErrorObject,
167 /// Echo of the corresponding request's id.
168 pub id: JsonValue,
169}
170
171/// JSON-RPC 2.0 error object.
172#[derive(Debug, Serialize)]
173pub struct ErrorObject {
174 /// Numeric error code per JSON-RPC convention.
175 pub code: i32,
176 /// Human-readable message.
177 pub message: String,
178}
179
180/// What the stdio loop should do with a parsed message — write a
181/// reply on stdout, or stay silent (notifications never receive a
182/// response).
183#[derive(Debug, PartialEq, Eq)]
184pub enum HandleOutcome {
185 /// Send the wrapped JSON payload back on stdout.
186 Reply(String),
187 /// Notification — no reply expected.
188 Silent,
189}
190
191/// Process one newline-delimited JSON-RPC message. The stdio loop
192/// in `main` calls this per line; tests call it with crafted
193/// strings.
194///
195/// # Examples
196///
197/// ```
198/// use noyalib_mcp::{handle_message, HandleOutcome};
199/// let req = r#"{"jsonrpc":"2.0","method":"ping","id":1}"#;
200/// match handle_message(req) {
201/// HandleOutcome::Reply(s) => assert!(s.contains("\"result\":{}")),
202/// HandleOutcome::Silent => panic!("expected reply"),
203/// }
204/// ```
205#[must_use]
206pub fn handle_message(raw: &str) -> HandleOutcome {
207 let req: Request = match serde_json::from_str(raw) {
208 Ok(r) => r,
209 Err(e) => {
210 return HandleOutcome::Reply(error_str(
211 JsonValue::Null,
212 -32700,
213 format!("parse error: {e}"),
214 ));
215 }
216 };
217 if req.jsonrpc != "2.0" {
218 return HandleOutcome::Reply(error_str(
219 req.id.unwrap_or(JsonValue::Null),
220 -32600,
221 "invalid request: jsonrpc must be \"2.0\"".to_string(),
222 ));
223 }
224 // Notifications (no id) get processed but never replied to.
225 let id = req.id.clone();
226 let result = dispatch(&req.method, req.params);
227 match (id, result) {
228 (None, _) => HandleOutcome::Silent,
229 (Some(id), Ok(value)) => HandleOutcome::Reply(
230 serde_json::to_string(&Response {
231 jsonrpc: "2.0",
232 result: value,
233 id,
234 })
235 .expect("infallible serialise"),
236 ),
237 (Some(id), Err((code, msg))) => HandleOutcome::Reply(error_str(id, code, msg)),
238 }
239}
240
241/// MCP method dispatcher. Returns the `result` payload on success
242/// or a `(code, message)` pair for the error envelope.
243///
244/// # Examples
245///
246/// ```
247/// use noyalib_mcp::dispatch;
248/// use serde_json::Value;
249/// let v = dispatch("ping", Value::Null).unwrap();
250/// assert!(v.is_object());
251/// ```
252pub fn dispatch(method: &str, params: JsonValue) -> Result<JsonValue, (i32, String)> {
253 match method {
254 "initialize" => Ok(json!({
255 "protocolVersion": "2025-06-18",
256 "serverInfo": {
257 "name": "noyalib-mcp",
258 "version": env!("CARGO_PKG_VERSION"),
259 },
260 "capabilities": {
261 "tools": {}
262 }
263 })),
264 "initialized" | "notifications/initialized" => Ok(JsonValue::Null),
265 "tools/list" => Ok(json!({
266 "tools": tools::descriptors()
267 })),
268 "tools/call" => tools::call(params),
269 "ping" => Ok(JsonValue::Object(serde_json::Map::new())),
270 other => Err((-32601, format!("method not found: {other}"))),
271 }
272}
273
274/// Render a JSON-RPC error envelope to a single line string.
275///
276/// # Examples
277///
278/// ```
279/// use noyalib_mcp::error_str;
280/// use serde_json::json;
281/// let s = error_str(json!(1), -32601, "method not found".into());
282/// assert!(s.contains("\"code\":-32601"));
283/// ```
284pub fn error_str(id: JsonValue, code: i32, message: String) -> String {
285 serde_json::to_string(&ErrorResponse {
286 jsonrpc: "2.0",
287 error: ErrorObject { code, message },
288 id,
289 })
290 .expect("infallible serialise")
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn parse_reply(out: HandleOutcome) -> JsonValue {
298 match out {
299 HandleOutcome::Reply(s) => serde_json::from_str(&s).unwrap(),
300 HandleOutcome::Silent => panic!("expected Reply, got Silent"),
301 }
302 }
303
304 // ── handle_message ─────────────────────────────────────────────────
305
306 #[test]
307 fn handle_message_returns_parse_error_on_bad_json() {
308 let out = handle_message("not json {");
309 let v = parse_reply(out);
310 assert_eq!(v["error"]["code"].as_i64().unwrap(), -32700);
311 assert!(
312 v["error"]["message"]
313 .as_str()
314 .unwrap()
315 .contains("parse error")
316 );
317 // Per JSON-RPC: parse errors carry `id: null`.
318 assert!(v["id"].is_null());
319 }
320
321 #[test]
322 fn handle_message_rejects_non_2_0_jsonrpc() {
323 let req = json!({"jsonrpc": "1.0", "method": "ping", "id": 1});
324 let out = handle_message(&req.to_string());
325 let v = parse_reply(out);
326 assert_eq!(v["error"]["code"].as_i64().unwrap(), -32600);
327 assert_eq!(v["id"].as_i64().unwrap(), 1);
328 }
329
330 #[test]
331 fn handle_message_returns_silent_for_notifications() {
332 let req = json!({"jsonrpc": "2.0", "method": "ping"});
333 let out = handle_message(&req.to_string());
334 assert_eq!(out, HandleOutcome::Silent);
335 }
336
337 #[test]
338 fn handle_message_returns_silent_for_notifications_initialized() {
339 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
340 let out = handle_message(&req.to_string());
341 assert_eq!(out, HandleOutcome::Silent);
342 }
343
344 #[test]
345 fn handle_message_returns_unknown_method_error() {
346 let req = json!({"jsonrpc": "2.0", "method": "frobnicate", "id": 7});
347 let out = handle_message(&req.to_string());
348 let v = parse_reply(out);
349 assert_eq!(v["error"]["code"].as_i64().unwrap(), -32601);
350 assert!(
351 v["error"]["message"]
352 .as_str()
353 .unwrap()
354 .contains("frobnicate")
355 );
356 assert_eq!(v["id"].as_i64().unwrap(), 7);
357 }
358
359 #[test]
360 fn handle_message_returns_jsonrpc_error_when_jsonrpc_field_missing() {
361 let req = json!({"method": "ping", "id": 1});
362 let out = handle_message(&req.to_string());
363 let v = parse_reply(out);
364 // Either parse error (missing field) or invalid request — both
365 // are valid envelopes; the contract is "you get an error".
366 assert!(v["error"].is_object());
367 }
368
369 // ── dispatch ──────────────────────────────────────────────────────
370
371 #[test]
372 fn dispatch_initialize_returns_protocol_metadata() {
373 let v = dispatch("initialize", JsonValue::Null).unwrap();
374 assert_eq!(v["protocolVersion"].as_str().unwrap(), "2025-06-18");
375 assert_eq!(v["serverInfo"]["name"].as_str().unwrap(), "noyalib-mcp");
376 assert!(v["capabilities"]["tools"].is_object());
377 }
378
379 #[test]
380 fn dispatch_initialized_returns_null() {
381 let v = dispatch("initialized", JsonValue::Null).unwrap();
382 assert!(v.is_null());
383 }
384
385 #[test]
386 fn dispatch_notifications_initialized_returns_null() {
387 let v = dispatch("notifications/initialized", JsonValue::Null).unwrap();
388 assert!(v.is_null());
389 }
390
391 #[test]
392 fn dispatch_tools_list_returns_descriptor_array() {
393 let v = dispatch("tools/list", JsonValue::Null).unwrap();
394 let tools = v["tools"].as_array().unwrap();
395 assert!(tools.iter().any(|t| t["name"] == "noyalib_get"));
396 assert!(tools.iter().any(|t| t["name"] == "noyalib_set"));
397 }
398
399 #[test]
400 fn dispatch_ping_returns_empty_object() {
401 let v = dispatch("ping", JsonValue::Null).unwrap();
402 assert!(v.is_object());
403 assert!(v.as_object().unwrap().is_empty());
404 }
405
406 #[test]
407 fn dispatch_unknown_method_returns_method_not_found() {
408 let err = dispatch("frobnicate", JsonValue::Null).unwrap_err();
409 assert_eq!(err.0, -32601);
410 assert!(err.1.contains("frobnicate"));
411 }
412
413 #[test]
414 fn dispatch_tools_call_propagates_tools_errors() {
415 // Missing `name` argument — tools::call returns -32602.
416 let err = dispatch("tools/call", json!({})).unwrap_err();
417 assert_eq!(err.0, -32602);
418 }
419
420 // ── error_str ─────────────────────────────────────────────────────
421
422 #[test]
423 fn error_str_renders_canonical_envelope() {
424 let s = error_str(json!(42), -32000, "boom".into());
425 let v: JsonValue = serde_json::from_str(&s).unwrap();
426 assert_eq!(v["jsonrpc"].as_str().unwrap(), "2.0");
427 assert_eq!(v["id"].as_i64().unwrap(), 42);
428 assert_eq!(v["error"]["code"].as_i64().unwrap(), -32000);
429 assert_eq!(v["error"]["message"].as_str().unwrap(), "boom");
430 }
431
432 #[test]
433 fn error_str_handles_null_id() {
434 let s = error_str(JsonValue::Null, -32700, "parse".into());
435 let v: JsonValue = serde_json::from_str(&s).unwrap();
436 assert!(v["id"].is_null());
437 }
438}