Skip to main content

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}