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`, `noyalib_set_multidoc`) is
16//! fixed. Optional `noyalib`
17//! features pulled in by a downstream packager (`schema`,
18//! `parallel`, …) do not change the MCP wire surface — they
19//! only affect what `noyalib::Error` messages can appear inside
20//! tool-call error envelopes. The canonical `noyalib` feature
21//! matrix lives in
22//! [`crates/noyalib/src/lib.rs`](https://docs.rs/noyalib).
23//!
24//! # MSRV
25//!
26//! **Rust 1.86.0** stable — same as the core `noyalib` library
27//! (and this crate's `rust-version`; an earlier revision of this
28//! paragraph said 1.75.0 while the manifest said 1.86.0 — the
29//! manifest is the contract).
30//! The MCP wire surface is text-only JSON-RPC and pulls no
31//! nightly-only deps. CI verifies the floor via the
32//! `Per-crate MSRV` workflow job. See the workspace
33//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/docs/POLICIES.md#1-msrv-minimum-supported-rust-version)
34//! for the bump policy.
35//!
36//! # Panics
37//!
38//! Public functions in this crate do not panic on well-formed
39//! input. The MCP binary `unwrap`s once on stdin acquisition
40//! during boot — that's deliberate, every caller invokes the
41//! binary via a host process that controls the pipe.
42//!
43//! # Errors
44//!
45//! Tool calls return JSON-RPC error envelopes per the
46//! [MCP specification](https://modelcontextprotocol.io). The
47//! error code taxonomy lives in
48//! [`docs/tools-reference.md`](https://github.com/sebastienrousseau/noyalib-mcp/blob/main/docs/tools-reference.md):
49//! `-32000` (file I/O), `-32001` (parse), `-32002` (path not
50//! found), `-32003` (set), `-32602` (missing arg), `-32601`
51//! (unknown method).
52//!
53//! # Concurrency
54//!
55//! Each MCP request is processed sequentially on the binary's
56//! stdio loop. The host (Claude Desktop, Cursor, Zed, …) is
57//! responsible for not pipelining requests; if it does, the
58//! tool execution is serialised by the loop's `BufRead` reader.
59//!
60//! # Platform support
61//!
62//! Tier-1 (CI-verified each PR): `aarch64-apple-darwin`,
63//! `x86_64-unknown-linux-gnu`, `x86_64-pc-windows-msvc`.
64//!
65//! `noyalib_set` writes via an *atomic file replacement*
66//! helper: write to a sibling temp file → `sync_all` →
67//! `rename`. This is naturally atomic on POSIX; on Windows it
68//! uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING |
69//! MOVEFILE_WRITE_THROUGH)` semantics so concurrent readers
70//! always see either the old or the new contents — never a
71//! half-write or a stale-page-cache observation. This was the
72//! fix for the historical Windows-only `tool_call_set_preserves_comments`
73//! flake.
74//!
75//! # Performance
76//!
77//! Each `tools/call` round-trip goes through one
78//! `noyalib::cst::parse_document` (`O(n)` over input bytes)
79//! and, for `noyalib_set`, one `Document::to_string` emit
80//! (`O(n)` over output bytes). JSON-RPC line framing is
81//! amortised constant-time per message. Tool calls do **not**
82//! cache the parsed CST between requests — every call is a
83//! fresh parse so concurrent edits from outside the MCP server
84//! are always observed. Typical tool-call latency on a 100 KB
85//! YAML file: 1–3 ms parse + emit on commodity hardware.
86//!
87//! # Security
88//!
89//! `#![forbid(unsafe_code)]`. No FFI. No network I/O —
90//! `noyalib-mcp` is stdio-only by design; remote hosting goes
91//! through a separate broker (see `examples/hosted-mcp-run.md`).
92//! The server has no auth layer; restrict the working
93//! directory of the spawned process via container mounts /
94//! systemd `ReadWritePaths=` for production deployments.
95//! Resource-limit gates are inherited from `noyalib`'s
96//! `ParserConfig` defaults. Full posture:
97//! [`SECURITY.md`](https://github.com/sebastienrousseau/noyalib/blob/main/SECURITY.md).
98//!
99//! # API stability and SemVer
100//!
101//! Pre-1.0 (`0.0.x`): the MCP wire contract (tool names,
102//! input-schema shapes, error code ranges, the
103//! [`SUPPORTED_PROTOCOL_VERSIONS`] set) is **stable** within a
104//! 0.0.x line — bug fixes only.
105//! Adding a new tool is allowed within a 0.0.x bump; removing
106//! or renaming a tool, or repurposing an error code, is held
107//! to a 0.x bump (e.g. 0.0.x → 0.1.0). The Rust library
108//! surface (`handle_message`, `dispatch`, `error_str`,
109//! `Request`, `Response`, `ErrorResponse`, `HandleOutcome`) is
110//! covered by the workspace SemVer policy in
111//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/docs/POLICIES.md#2-semver--api-stability).
112//! `cargo-semver-checks` runs in CI on every PR.
113//!
114//! # Documentation
115//!
116//! - **Engineering policies** — workspace
117//!   [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/docs/POLICIES.md).
118//! - **MCP specification**: <https://modelcontextprotocol.io>.
119//! - **Tools reference** (input schemas, error codes):
120//!   [`docs/tools-reference.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-mcp/doc/tools-reference.md).
121//! - **Host configurations** (Claude Desktop, Cursor,
122//!   Continue.dev, Zed, hosted gateways):
123//!   [`examples/`](https://github.com/sebastienrousseau/noyalib/tree/main/crates/noyalib-mcp/examples).
124
125#![forbid(unsafe_code)]
126#![warn(missing_docs)]
127// Opt-in coverage exclusion (`NOYALIB_COVERAGE=1`) — see
128// `build.rs` for the flag, individual `coverage(off)` annotations
129// are below.
130#![cfg_attr(noyalib_coverage, allow(unstable_features))]
131#![cfg_attr(noyalib_coverage, feature(coverage_attribute))]
132
133use serde::{Deserialize, Serialize};
134use serde_json::{Value as JsonValue, json};
135
136pub mod prompts;
137pub mod resources;
138pub mod tools;
139
140/// Protocol revisions this server speaks, newest first. The server
141/// is **dual-era** (MCP 2026-07-28 "Versioning and Compatibility"
142/// terminology): a modern client declares its version per request in
143/// `_meta` and may probe with `server/discover`; a legacy client
144/// opens with an `initialize` handshake and negotiates
145/// [`LEGACY_PROTOCOL_VERSION`].
146pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] = ["2026-07-28", "2025-06-18"];
147
148/// The newest handshake-based revision this server implements —
149/// what `initialize` answers when the client's requested version is
150/// not supported (per the 2025-06-18 negotiation rules, the server
151/// then responds with a version it does support).
152pub const LEGACY_PROTOCOL_VERSION: &str = "2025-06-18";
153
154/// The `_meta` key a modern (2026-07-28+) client uses to declare the
155/// protocol revision of each request.
156pub const META_PROTOCOL_VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
157
158/// `_meta` key under which each result identifies this server
159/// (2026-07-28 "servers SHOULD identify themselves in each result's
160/// `_meta`").
161const META_SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
162
163/// `UnsupportedProtocolVersionError` code (2026-07-28 error-code
164/// allocation: `-32020..=-32099` reserved for the MCP spec).
165pub const UNSUPPORTED_PROTOCOL_VERSION: i32 = -32022;
166
167/// One hour, in milliseconds — the `ttlMs` freshness hint on the
168/// cacheable results (`tools/list`, `prompts/list`,
169/// `resources/list`, `resources/templates/list`, `resources/read`).
170/// The catalogue is fixed per binary, so any bound would do; an
171/// hour keeps re-listing cheap without making a stale cache
172/// survive a server upgrade for long.
173const CACHE_TTL_MS: u64 = 3_600_000;
174
175/// JSON-RPC 2.0 request envelope. Method-specific parameters live
176/// in [`JsonValue`] to keep parsing flexible across the few methods
177/// the MCP spec asks of a server.
178#[derive(Debug, Deserialize)]
179pub struct Request {
180    /// JSON-RPC version. MCP requires `"2.0"`.
181    pub jsonrpc: String,
182    /// Method name, e.g. `tools/call`. Notifications have no `id`.
183    pub method: String,
184    /// Method parameters. Shape depends on `method`.
185    #[serde(default)]
186    pub params: JsonValue,
187    /// Request id; absent on notifications.
188    pub id: Option<JsonValue>,
189}
190
191/// JSON-RPC 2.0 success response envelope.
192#[derive(Debug, Serialize)]
193pub struct Response {
194    /// Always `"2.0"`.
195    pub jsonrpc: &'static str,
196    /// The result payload.
197    pub result: JsonValue,
198    /// Echo of the corresponding request's id.
199    pub id: JsonValue,
200}
201
202/// JSON-RPC 2.0 error envelope.
203#[derive(Debug, Serialize)]
204pub struct ErrorResponse {
205    /// Always `"2.0"`.
206    pub jsonrpc: &'static str,
207    /// Error payload.
208    pub error: ErrorObject,
209    /// Echo of the corresponding request's id.
210    pub id: JsonValue,
211}
212
213/// JSON-RPC 2.0 error object.
214#[derive(Debug, Serialize)]
215pub struct ErrorObject {
216    /// Numeric error code per JSON-RPC convention.
217    pub code: i32,
218    /// Human-readable message.
219    pub message: String,
220    /// Optional structured detail — e.g.
221    /// `UnsupportedProtocolVersionError` carries
222    /// `{"supported": […], "requested": "…"}` so a modern client
223    /// can pick a mutually supported revision and retry.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub data: Option<JsonValue>,
226}
227
228/// What the stdio loop should do with a parsed message — write a
229/// reply on stdout, or stay silent (notifications never receive a
230/// response).
231#[derive(Debug, PartialEq, Eq)]
232pub enum HandleOutcome {
233    /// Send the wrapped JSON payload back on stdout.
234    Reply(String),
235    /// Notification — no reply expected.
236    Silent,
237}
238
239/// Process one newline-delimited JSON-RPC message. The stdio loop
240/// in `main` calls this per line; tests call it with crafted
241/// strings.
242///
243/// # Examples
244///
245/// ```
246/// use noyalib_mcp::{handle_message, HandleOutcome};
247/// let req = r#"{"jsonrpc":"2.0","method":"ping","id":1}"#;
248/// match handle_message(req) {
249///     // Every result carries the 2026-07-28 envelope fields.
250///     HandleOutcome::Reply(s) => assert!(s.contains("\"resultType\":\"complete\"")),
251///     HandleOutcome::Silent => panic!("expected reply"),
252/// }
253/// ```
254#[must_use]
255pub fn handle_message(raw: &str) -> HandleOutcome {
256    let req: Request = match serde_json::from_str(raw) {
257        Ok(r) => r,
258        Err(e) => {
259            return HandleOutcome::Reply(error_str(
260                JsonValue::Null,
261                -32700,
262                format!("parse error: {e}"),
263            ));
264        }
265    };
266    if req.jsonrpc != "2.0" {
267        return HandleOutcome::Reply(error_str(
268            req.id.unwrap_or(JsonValue::Null),
269            -32600,
270            "invalid request: jsonrpc must be \"2.0\"".to_string(),
271        ));
272    }
273    // Notifications (no id) get processed but never replied to.
274    let id = req.id.clone();
275
276    // Modern (2026-07-28) clients declare their protocol revision on
277    // every request in `_meta`. An unsupported declaration MUST be
278    // answered with `UnsupportedProtocolVersionError` listing what
279    // the server does speak; an absent one means a legacy client (or
280    // a modern client relying on the inline-retry flow) and the
281    // request proceeds.
282    if let Some(requested) = req
283        .params
284        .get("_meta")
285        .and_then(|m| m.get(META_PROTOCOL_VERSION_KEY))
286        .and_then(JsonValue::as_str)
287    {
288        if !SUPPORTED_PROTOCOL_VERSIONS.contains(&requested) {
289            return match id {
290                None => HandleOutcome::Silent,
291                Some(id) => HandleOutcome::Reply(
292                    serde_json::to_string(&ErrorResponse {
293                        jsonrpc: "2.0",
294                        error: ErrorObject {
295                            code: UNSUPPORTED_PROTOCOL_VERSION,
296                            message: "Unsupported protocol version".to_string(),
297                            data: Some(json!({
298                                "supported": SUPPORTED_PROTOCOL_VERSIONS,
299                                "requested": requested,
300                            })),
301                        },
302                        id,
303                    })
304                    .expect("infallible serialise"),
305                ),
306            };
307        }
308    }
309
310    let result = dispatch(&req.method, req.params);
311    match (id, result) {
312        (None, _) => HandleOutcome::Silent,
313        (Some(id), Ok(value)) => HandleOutcome::Reply(
314            serde_json::to_string(&Response {
315                jsonrpc: "2.0",
316                result: decorate_result(value),
317                id,
318            })
319            .expect("infallible serialise"),
320        ),
321        (Some(id), Err((code, msg))) => HandleOutcome::Reply(error_str(id, code, msg)),
322    }
323}
324
325/// Stamp the 2026-07-28 result envelope fields onto a dispatch
326/// result: `resultType: "complete"` (required on every result since
327/// SEP-2322; legacy clients ignore unknown fields, and clients MUST
328/// read an absent field as `"complete"`, so stamping is always
329/// safe) and the server's identity in `_meta` (a SHOULD). Non-object
330/// results — the `null` a legacy `initialized` sent *with* an id
331/// gets back — pass through untouched.
332fn decorate_result(mut value: JsonValue) -> JsonValue {
333    if let JsonValue::Object(map) = &mut value {
334        let _ = map.entry("resultType").or_insert_with(|| json!("complete"));
335        let meta = map.entry("_meta").or_insert_with(|| json!({}));
336        if let Some(meta) = meta.as_object_mut() {
337            let _ = meta.entry(META_SERVER_INFO_KEY).or_insert_with(|| {
338                json!({
339                    "name": "noyalib-mcp",
340                    "version": env!("CARGO_PKG_VERSION"),
341                })
342            });
343        }
344    }
345    value
346}
347
348/// MCP method dispatcher. Returns the `result` payload on success
349/// or a `(code, message)` pair for the error envelope.
350///
351/// # Examples
352///
353/// ```
354/// use noyalib_mcp::dispatch;
355/// use serde_json::Value;
356/// let v = dispatch("ping", Value::Null).unwrap();
357/// assert!(v.is_object());
358/// ```
359pub fn dispatch(method: &str, params: JsonValue) -> Result<JsonValue, (i32, String)> {
360    match method {
361        // Legacy (handshake-era) lifecycle. A dual-era server keeps
362        // answering `initialize`: the 2026-07-28 stateless flow never
363        // sends it, so its presence identifies a legacy client.
364        "initialize" => {
365            // 2025-06-18 negotiation: echo the requested version when
366            // this server supports it; otherwise answer with the
367            // newest handshake-based revision it does support and
368            // let the client decide. The previous implementation
369            // hard-coded the answer and ignored the request.
370            let requested = params.get("protocolVersion").and_then(JsonValue::as_str);
371            let negotiated = match requested {
372                Some(v) if SUPPORTED_PROTOCOL_VERSIONS.contains(&v) => v,
373                _ => LEGACY_PROTOCOL_VERSION,
374            };
375            Ok(json!({
376                "protocolVersion": negotiated,
377                "serverInfo": {
378                    "name": "noyalib-mcp",
379                    "version": env!("CARGO_PKG_VERSION"),
380                },
381                "capabilities": {
382                    "tools": {},
383                    "prompts": {},
384                    "resources": {}
385                }
386            }))
387        }
388        "initialized" | "notifications/initialized" => Ok(JsonValue::Null),
389        // Modern (2026-07-28) discovery — MUST be implemented; also
390        // the stdio backward-compatibility probe a dual-era client
391        // sends before deciding which era this server belongs to.
392        "server/discover" => Ok(json!({
393            "supportedVersions": SUPPORTED_PROTOCOL_VERSIONS,
394            "capabilities": {
395                "tools": {},
396                "prompts": {},
397                "resources": {}
398            },
399            "instructions": "Read and edit YAML files losslessly: \
400                             noyalib_get reads the value at a path, \
401                             noyalib_set / noyalib_set_multidoc \
402                             rewrite one value while preserving all \
403                             comments and formatting.",
404            "ttlMs": CACHE_TTL_MS,
405            "cacheScope": "public",
406        })),
407        "tools/list" => Ok(json!({
408            "tools": tools::descriptors(),
409            "ttlMs": CACHE_TTL_MS,
410            "cacheScope": "public",
411        })),
412        "tools/call" => tools::call(params),
413        "prompts/list" => Ok(json!({
414            "prompts": prompts::descriptors(),
415            "ttlMs": CACHE_TTL_MS,
416            "cacheScope": "public",
417        })),
418        "prompts/get" => prompts::get(params),
419        "resources/list" => Ok(json!({
420            "resources": resources::descriptors(),
421            "ttlMs": CACHE_TTL_MS,
422            "cacheScope": "public",
423        })),
424        "resources/templates/list" => Ok(json!({
425            "resourceTemplates": resources::templates(),
426            "ttlMs": CACHE_TTL_MS,
427            "cacheScope": "public",
428        })),
429        "resources/read" => resources::read(params).map(|mut v| {
430            // `resources/read` is cacheable too (SEP-2549); the
431            // served documents are fixed per binary.
432            if let JsonValue::Object(map) = &mut v {
433                let _ = map.entry("ttlMs").or_insert_with(|| json!(CACHE_TTL_MS));
434                let _ = map.entry("cacheScope").or_insert_with(|| json!("public"));
435            }
436            v
437        }),
438        // Removed in 2026-07-28 but kept for legacy clients
439        // (implementation-defined methods are grandfathered; a
440        // modern client simply never sends it).
441        "ping" => Ok(JsonValue::Object(serde_json::Map::new())),
442        other => Err((-32601, format!("method not found: {other}"))),
443    }
444}
445
446/// Render a JSON-RPC error envelope to a single line string.
447///
448/// # Examples
449///
450/// ```
451/// use noyalib_mcp::error_str;
452/// use serde_json::json;
453/// let s = error_str(json!(1), -32601, "method not found".into());
454/// assert!(s.contains("\"code\":-32601"));
455/// ```
456pub fn error_str(id: JsonValue, code: i32, message: String) -> String {
457    serde_json::to_string(&ErrorResponse {
458        jsonrpc: "2.0",
459        error: ErrorObject {
460            code,
461            message,
462            data: None,
463        },
464        id,
465    })
466    .expect("infallible serialise")
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    fn parse_reply(out: HandleOutcome) -> JsonValue {
474        match out {
475            HandleOutcome::Reply(s) => serde_json::from_str(&s).unwrap(),
476            HandleOutcome::Silent => panic!("expected Reply, got Silent"),
477        }
478    }
479
480    // ── handle_message ─────────────────────────────────────────────────
481
482    #[test]
483    fn handle_message_returns_parse_error_on_bad_json() {
484        let out = handle_message("not json {");
485        let v = parse_reply(out);
486        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32700);
487        assert!(
488            v["error"]["message"]
489                .as_str()
490                .unwrap()
491                .contains("parse error")
492        );
493        // Per JSON-RPC: parse errors carry `id: null`.
494        assert!(v["id"].is_null());
495    }
496
497    #[test]
498    fn handle_message_rejects_non_2_0_jsonrpc() {
499        let req = json!({"jsonrpc": "1.0", "method": "ping", "id": 1});
500        let out = handle_message(&req.to_string());
501        let v = parse_reply(out);
502        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32600);
503        assert_eq!(v["id"].as_i64().unwrap(), 1);
504    }
505
506    #[test]
507    fn handle_message_returns_silent_for_notifications() {
508        let req = json!({"jsonrpc": "2.0", "method": "ping"});
509        let out = handle_message(&req.to_string());
510        assert_eq!(out, HandleOutcome::Silent);
511    }
512
513    #[test]
514    fn handle_message_returns_silent_for_notifications_initialized() {
515        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
516        let out = handle_message(&req.to_string());
517        assert_eq!(out, HandleOutcome::Silent);
518    }
519
520    #[test]
521    fn handle_message_returns_unknown_method_error() {
522        let req = json!({"jsonrpc": "2.0", "method": "frobnicate", "id": 7});
523        let out = handle_message(&req.to_string());
524        let v = parse_reply(out);
525        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32601);
526        assert!(
527            v["error"]["message"]
528                .as_str()
529                .unwrap()
530                .contains("frobnicate")
531        );
532        assert_eq!(v["id"].as_i64().unwrap(), 7);
533    }
534
535    #[test]
536    fn handle_message_returns_jsonrpc_error_when_jsonrpc_field_missing() {
537        let req = json!({"method": "ping", "id": 1});
538        let out = handle_message(&req.to_string());
539        let v = parse_reply(out);
540        // Either parse error (missing field) or invalid request — both
541        // are valid envelopes; the contract is "you get an error".
542        assert!(v["error"].is_object());
543    }
544
545    // ── dispatch ──────────────────────────────────────────────────────
546
547    #[test]
548    fn dispatch_initialize_returns_protocol_metadata() {
549        let v = dispatch("initialize", JsonValue::Null).unwrap();
550        assert_eq!(v["protocolVersion"].as_str().unwrap(), "2025-06-18");
551        assert_eq!(v["serverInfo"]["name"].as_str().unwrap(), "noyalib-mcp");
552        assert!(v["capabilities"]["tools"].is_object());
553        assert!(v["capabilities"]["prompts"].is_object());
554        assert!(v["capabilities"]["resources"].is_object());
555    }
556
557    // ── dual-era protocol (2026-07-28) ────────────────────────────────
558
559    #[test]
560    fn initialize_echoes_a_supported_requested_version() {
561        for v in SUPPORTED_PROTOCOL_VERSIONS {
562            let r = dispatch("initialize", json!({"protocolVersion": v})).unwrap();
563            assert_eq!(r["protocolVersion"].as_str().unwrap(), v, "requested {v}");
564        }
565    }
566
567    #[test]
568    fn initialize_answers_legacy_for_an_unknown_version() {
569        // Per the 2025-06-18 negotiation rules the server responds
570        // with a version it does support; the client then decides.
571        // The previous implementation ignored the request entirely.
572        let r = dispatch("initialize", json!({"protocolVersion": "2024-11-05"})).unwrap();
573        assert_eq!(
574            r["protocolVersion"].as_str().unwrap(),
575            LEGACY_PROTOCOL_VERSION
576        );
577    }
578
579    #[test]
580    fn server_discover_lists_versions_and_capabilities() {
581        let v = dispatch("server/discover", JsonValue::Null).unwrap();
582        let versions: Vec<&str> = v["supportedVersions"]
583            .as_array()
584            .unwrap()
585            .iter()
586            .map(|s| s.as_str().unwrap())
587            .collect();
588        assert_eq!(versions, SUPPORTED_PROTOCOL_VERSIONS);
589        assert!(v["capabilities"]["tools"].is_object());
590        assert!(v["ttlMs"].is_u64());
591        assert_eq!(v["cacheScope"].as_str().unwrap(), "public");
592    }
593
594    #[test]
595    fn results_carry_the_modern_envelope_fields() {
596        let req = json!({"jsonrpc": "2.0", "method": "tools/list", "id": 7});
597        let v = parse_reply(handle_message(&req.to_string()));
598        assert_eq!(v["result"]["resultType"].as_str().unwrap(), "complete");
599        assert_eq!(
600            v["result"]["_meta"][META_SERVER_INFO_KEY]["name"]
601                .as_str()
602                .unwrap(),
603            "noyalib-mcp"
604        );
605        assert!(v["result"]["ttlMs"].is_u64());
606        assert_eq!(v["result"]["cacheScope"].as_str().unwrap(), "public");
607    }
608
609    #[test]
610    fn a_supported_meta_version_is_served() {
611        let req = json!({
612            "jsonrpc": "2.0",
613            "method": "tools/list",
614            "id": 8,
615            "params": {"_meta": {META_PROTOCOL_VERSION_KEY: "2026-07-28"}},
616        });
617        let v = parse_reply(handle_message(&req.to_string()));
618        assert!(v["result"]["tools"].is_array());
619    }
620
621    #[test]
622    fn an_unsupported_meta_version_is_refused_with_the_supported_list() {
623        let req = json!({
624            "jsonrpc": "2.0",
625            "method": "tools/list",
626            "id": 9,
627            "params": {"_meta": {META_PROTOCOL_VERSION_KEY: "1900-01-01"}},
628        });
629        let v = parse_reply(handle_message(&req.to_string()));
630        assert_eq!(
631            v["error"]["code"].as_i64().unwrap(),
632            i64::from(UNSUPPORTED_PROTOCOL_VERSION)
633        );
634        assert_eq!(v["error"]["data"]["requested"], "1900-01-01");
635        let supported = v["error"]["data"]["supported"].as_array().unwrap();
636        assert_eq!(supported.len(), SUPPORTED_PROTOCOL_VERSIONS.len());
637    }
638
639    #[test]
640    fn resources_read_is_cacheable() {
641        let v = dispatch("resources/read", json!({"uri": "noyalib://tools"})).unwrap();
642        assert!(v["ttlMs"].is_u64());
643        assert_eq!(v["cacheScope"].as_str().unwrap(), "public");
644    }
645
646    #[test]
647    fn dispatch_prompts_list_returns_prompt_array() {
648        let v = dispatch("prompts/list", JsonValue::Null).unwrap();
649        let prompts = v["prompts"].as_array().unwrap();
650        assert!(prompts.iter().any(|p| p["name"] == "format_and_lint_yaml"));
651    }
652
653    #[test]
654    fn dispatch_prompts_get_returns_messages() {
655        let v = dispatch("prompts/get", json!({"name": "format_and_lint_yaml"})).unwrap();
656        assert!(v["messages"].as_array().unwrap().len() == 1);
657    }
658
659    #[test]
660    fn dispatch_resources_list_returns_resource_array() {
661        let v = dispatch("resources/list", JsonValue::Null).unwrap();
662        let resources = v["resources"].as_array().unwrap();
663        assert!(resources.iter().any(|r| r["uri"] == "noyalib://tools"));
664    }
665
666    #[test]
667    fn dispatch_resources_templates_list_returns_templates() {
668        let v = dispatch("resources/templates/list", JsonValue::Null).unwrap();
669        let templates = v["resourceTemplates"].as_array().unwrap();
670        assert!(
671            templates
672                .iter()
673                .any(|t| t["uriTemplate"] == "noyalib://tool/{name}")
674        );
675    }
676
677    #[test]
678    fn dispatch_resources_read_returns_contents() {
679        let v = dispatch("resources/read", json!({"uri": "noyalib://error-codes"})).unwrap();
680        assert!(v["contents"].as_array().unwrap().len() == 1);
681    }
682
683    #[test]
684    fn dispatch_initialized_returns_null() {
685        let v = dispatch("initialized", JsonValue::Null).unwrap();
686        assert!(v.is_null());
687    }
688
689    #[test]
690    fn dispatch_notifications_initialized_returns_null() {
691        let v = dispatch("notifications/initialized", JsonValue::Null).unwrap();
692        assert!(v.is_null());
693    }
694
695    #[test]
696    fn dispatch_tools_list_returns_descriptor_array() {
697        let v = dispatch("tools/list", JsonValue::Null).unwrap();
698        let tools = v["tools"].as_array().unwrap();
699        assert!(tools.iter().any(|t| t["name"] == "noyalib_get"));
700        assert!(tools.iter().any(|t| t["name"] == "noyalib_set"));
701    }
702
703    #[test]
704    fn dispatch_ping_returns_empty_object() {
705        let v = dispatch("ping", JsonValue::Null).unwrap();
706        assert!(v.is_object());
707        assert!(v.as_object().unwrap().is_empty());
708    }
709
710    #[test]
711    fn dispatch_unknown_method_returns_method_not_found() {
712        let err = dispatch("frobnicate", JsonValue::Null).unwrap_err();
713        assert_eq!(err.0, -32601);
714        assert!(err.1.contains("frobnicate"));
715    }
716
717    #[test]
718    fn dispatch_tools_call_propagates_tools_errors() {
719        // Missing `name` argument — tools::call returns -32602.
720        let err = dispatch("tools/call", json!({})).unwrap_err();
721        assert_eq!(err.0, -32602);
722    }
723
724    // ── error_str ─────────────────────────────────────────────────────
725
726    #[test]
727    fn error_str_renders_canonical_envelope() {
728        let s = error_str(json!(42), -32000, "boom".into());
729        let v: JsonValue = serde_json::from_str(&s).unwrap();
730        assert_eq!(v["jsonrpc"].as_str().unwrap(), "2.0");
731        assert_eq!(v["id"].as_i64().unwrap(), 42);
732        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32000);
733        assert_eq!(v["error"]["message"].as_str().unwrap(), "boom");
734    }
735
736    #[test]
737    fn error_str_handles_null_id() {
738        let s = error_str(JsonValue::Null, -32700, "parse".into());
739        let v: JsonValue = serde_json::from_str(&s).unwrap();
740        assert!(v["id"].is_null());
741    }
742}