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}