From 01df6b790506e3cde229acc542f7e1f755ab30b2 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 01/11] refactor: fix clippy lints from Rust 1.98 Clippy 0.1.98 flags two new lints that break the CI step `cargo clippy --all-targets -- -D warnings` on stable: - chunks_exact_to_as_chunks (attachments.rs): iterate the /Names leaf with `as_chunks::<2>()`. The trailing odd element is still skipped, as with `chunks_exact(2)`. - collapsible_match (layout.rs): move the "no trailing space" check into the TJ match guards. A failing guard falls through to `_ => {}`, which is what the inner `if` did. No behaviour change. `as_chunks` needs Rust 1.88 or newer. --- src/document/attachments.rs | 5 ++--- src/document/layout.rs | 12 ++++-------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/document/attachments.rs b/src/document/attachments.rs index 8abc976..2dfc97b 100644 --- a/src/document/attachments.rs +++ b/src/document/attachments.rs @@ -158,9 +158,8 @@ fn traverse_name_tree( }; if let Some(arr) = names_arr { - for chunk in arr.chunks_exact(2) { - let name_hint = object_to_string(&chunk[0]); - let filespec_obj = &chunk[1]; + for [name_obj, filespec_obj] in arr.as_chunks::<2>().0 { + let name_hint = object_to_string(name_obj); if let Some(att) = parse_filespec(doc, filespec_obj, name_hint, None, seen_stream_ids) { diff --git a/src/document/layout.rs b/src/document/layout.rs index dde61b6..69d155e 100644 --- a/src/document/layout.rs +++ b/src/document/layout.rs @@ -351,15 +351,11 @@ pub fn extract_positioned_fragments( let part = decode_bytes_with_encoding(bytes, enc); combined_text.push_str(&part); } - Object::Integer(i) if *i < -100 => { - if !combined_text.ends_with(' ') { - combined_text.push(' '); - } + Object::Integer(i) if *i < -100 && !combined_text.ends_with(' ') => { + combined_text.push(' '); } - Object::Real(r) if *r < -100.0 => { - if !combined_text.ends_with(' ') { - combined_text.push(' '); - } + Object::Real(r) if *r < -100.0 && !combined_text.ends_with(' ') => { + combined_text.push(' '); } _ => {} } From 839c5bf9cc4f092370fc7b581dc3280e933667f7 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 02/11] chore: track Cargo.lock docugraph ships a binary, so the lockfile belongs in the repo. Without it every build resolves dependencies again and a fixed commit does not pin rmcp, lopdf, tokio and the rest. The lock is the one resolved for 2832efa (182 packages, rmcp 3.3.0, lopdf 0.45.0, tokio 1.53.1) and matches Cargo.toml under `--locked`. --- .gitignore | 1 - Cargo.lock | 1681 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1681 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock diff --git a/.gitignore b/.gitignore index 7f039cb..82b852a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Generated by Cargo /target/ **/*.rs.bk -Cargo.lock # Environment & Secrets (Never commit secrets!) .env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..251b1e3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1681 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "docugraph" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "hex", + "lopdf", + "miniz_oxide 0.8.9", + "rmcp", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lopdf" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3" +dependencies = [ + "aes", + "bitflags", + "brotli-decompressor", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom", + "indexmap", + "itoa", + "log", + "md-5", + "nom", + "rand", + "rangemap", + "rayon", + "serde", + "sha2 0.11.0", + "stringprep", + "thiserror", + "weezl", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmcp" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88db56b8ae316560e9e868b6b978ea940f27cb883323fc90e07435e44158f5c" +dependencies = [ + "base64", + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873b730df6f0a9b74b13eb514e0dca4c2db0d8b68b74af98a2e9bf3f9d436585" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.5", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From d0cccd55b32b62fbb4e19b17813bc22a9699daef Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 03/11] test: gate real PDF test behind env var test_real_pdf_loading_and_extraction read a hardcoded Windows path and returned early when it was missing, so it reported "ok" without testing anything on every machine but one. Read the path from DOCUGRAPH_TEST_PDF instead and mark the test #[ignore] with a reason, so it shows up as ignored by default. Run it with `DOCUGRAPH_TEST_PDF=... cargo test -- --ignored`; a missing variable or file now fails loudly. --- CONTRIBUTING.md | 3 +++ tests/pdf_ingestion_test.rs | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f38b8f2..0b95f49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,9 @@ cargo clippy --all-targets -- -D warnings # 3. Suite completa de pruebas pasando cargo test --all + +# 4. (Opcional) Test con un PDF real, ignorado por defecto +DOCUGRAPH_TEST_PDF=/ruta/a/libro.pdf cargo test --test pdf_ingestion_test -- --ignored ``` --- diff --git a/tests/pdf_ingestion_test.rs b/tests/pdf_ingestion_test.rs index e901dbe..633d100 100644 --- a/tests/pdf_ingestion_test.rs +++ b/tests/pdf_ingestion_test.rs @@ -119,15 +119,21 @@ fn test_provenance_citation_formatting() { ); } +/// Parses a real digital PDF with more than 200 pages end to end. +/// +/// Ignored by default because the repository ships no PDFs. Run it with: +/// `DOCUGRAPH_TEST_PDF=/path/to/book.pdf cargo test --test pdf_ingestion_test -- --ignored` #[test] +#[ignore = "needs a real PDF: set DOCUGRAPH_TEST_PDF and run with --ignored"] fn test_real_pdf_loading_and_extraction() { - let sample_pdf_path = "C:/proyectos/chaika-stage/docs/aprendiendo-git-pdf.pdf"; - if !std::path::Path::new(sample_pdf_path).exists() { - eprintln!("Sample PDF not found; skipping real PDF loading test"); - return; - } + let sample_pdf_path = std::env::var("DOCUGRAPH_TEST_PDF") + .expect("DOCUGRAPH_TEST_PDF must point to a real PDF with more than 200 pages"); + assert!( + std::path::Path::new(&sample_pdf_path).is_file(), + "DOCUGRAPH_TEST_PDF is not a file: {sample_pdf_path}" + ); - let doc = load_pdf_from_path(sample_pdf_path).expect("PDF should parse without error"); + let doc = load_pdf_from_path(&sample_pdf_path).expect("PDF should parse without error"); assert!(doc.metadata.total_pages > 200, "Should have over 200 pages"); assert!( From d2d6941e50a3be7895365c4fbe1b9a3f4b2f744e Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 04/11] ci: run clippy and tests with --locked Now that Cargo.lock is tracked, fail CI when it is out of date with Cargo.toml instead of silently rewriting it. There is no separate build step: clippy is the first step that resolves dependencies and `cargo test` builds the crate, so both get --locked. Without it on clippy the lock would be rewritten before the test step runs. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5d68ef..f1cf7eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: cargo fmt --all -- --check - name: Run Clippy Linter (cargo clippy) - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --locked --all-targets -- -D warnings - name: Run Automated Tests (cargo test) - run: cargo test --all --verbose + run: cargo test --all --locked --verbose From dd59f5f7b86ca87ec63444460c72cb72ffbac18b Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:25:10 +0000 Subject: [PATCH 05/11] test: keep server test caches out of the repo DocuGraphServer::new() reads DOCUGRAPH_CACHE_DIR and falls back to a relative .docugraph_cache, so the protocol and scanned-page tests wrote cache JSON into the working directory on every run. Add DocuGraphServer::with_cache_dir and point those tests at a tempdir. --- src/mcp/server.rs | 14 +++++++++++--- tests/mcp_protocol_test.rs | 15 +++++++++++---- tests/scanned_document_detection_test.rs | 3 ++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 8796871..c9d4b62 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -6,6 +6,7 @@ use rmcp::{ model::{ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router, }; +use std::path::Path; use tracing::info; use super::tools::*; @@ -44,10 +45,17 @@ impl Default for DocuGraphServer { impl DocuGraphServer { /// Create a new server instance with the auto-generated tool router and disk-backed store. + /// + /// The cache directory comes from `DOCUGRAPH_CACHE_DIR` (see [`DiskCache::default_dir`]). pub fn new() -> Self { - let cache_dir = DiskCache::default_dir(); - let cache = DiskCache::new(&cache_dir).ok(); - let renderer = CachedPageRendererProxy::new(Some(&cache_dir)); + Self::with_cache_dir(DiskCache::default_dir()) + } + + /// Create a server whose document store and page renders live in `cache_dir`. + pub fn with_cache_dir(cache_dir: impl AsRef) -> Self { + let cache_dir = cache_dir.as_ref(); + let cache = DiskCache::new(cache_dir).ok(); + let renderer = CachedPageRendererProxy::new(Some(cache_dir)); Self { tool_router: Self::tool_router(), store: DocumentStore::new(cache), diff --git a/tests/mcp_protocol_test.rs b/tests/mcp_protocol_test.rs index 60fc2a6..89e7f04 100644 --- a/tests/mcp_protocol_test.rs +++ b/tests/mcp_protocol_test.rs @@ -4,10 +4,17 @@ use docugraph::mcp::{ tools::{DocumentInfoParams, PingParams}, }; use rmcp::{ServerHandler, handler::server::wrapper::Parameters}; +use tempfile::{TempDir, tempdir}; + +/// Server backed by a throwaway cache dir, so tests never write `.docugraph_cache` into the repo. +fn server_with_temp_cache() -> (DocuGraphServer, TempDir) { + let cache = tempdir().expect("create temp cache dir"); + (DocuGraphServer::with_cache_dir(cache.path()), cache) +} #[tokio::test] async fn test_server_info_and_capabilities() { - let server = DocuGraphServer::new(); + let (server, _cache) = server_with_temp_cache(); let info = server.get_info(); assert_eq!(info.server_info.name, "docugraph-mcp"); @@ -17,7 +24,7 @@ async fn test_server_info_and_capabilities() { #[tokio::test] async fn test_document_ping_tool() { - let server = DocuGraphServer::new(); + let (server, _cache) = server_with_temp_cache(); // Default ping message let resp = server @@ -36,7 +43,7 @@ async fn test_document_ping_tool() { #[tokio::test] async fn test_document_list_tool() { - let server = DocuGraphServer::new(); + let (server, _cache) = server_with_temp_cache(); let doc = Document::new(DocumentMetadata { id: "sample-doc".to_string(), title: "Sample Doc".to_string(), @@ -70,7 +77,7 @@ async fn test_document_list_tool() { #[tokio::test] async fn test_document_info_tool() { - let server = DocuGraphServer::new(); + let (server, _cache) = server_with_temp_cache(); let doc = Document::new(DocumentMetadata { id: "test_doc_gof".to_string(), title: "GoF Design Patterns".to_string(), diff --git a/tests/scanned_document_detection_test.rs b/tests/scanned_document_detection_test.rs index 22d194d..0d2aa32 100644 --- a/tests/scanned_document_detection_test.rs +++ b/tests/scanned_document_detection_test.rs @@ -200,7 +200,8 @@ fn test_blank_page_classified_as_empty() { #[tokio::test] async fn test_mcp_server_scanned_warnings_in_tools() { - let server = DocuGraphServer::new(); + let cache = tempfile::tempdir().expect("create temp cache dir"); + let server = DocuGraphServer::with_cache_dir(cache.path()); let mut doc = Document::new(DocumentMetadata { id: "doc-with-scans".to_string(), title: "Manual Escaneado".to_string(), From e83da1ed82c6154bb257b231114f0aebb1634463 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:30:54 +0000 Subject: [PATCH 06/11] fix(mcp): return isError for domain errors Tools returned plain strings, so rmcp wrapped every failure as a successful result: an unknown document, section, page or attachment came back with isError=false, and document_info even built a fake "Document Not Found" object with zero pages. Add ToolError, which becomes CallToolResult::error, and return Result from every tool that looks something up. Not-found errors name what exists instead: the available document ids or the document's attachments. A page number or page filter outside the document is an error too. --- src/mcp/error.rs | 48 ++ src/mcp/mod.rs | 2 + src/mcp/server.rs | 726 ++++++++++--------- tests/attachments_and_embedded_files_test.rs | 28 +- tests/forms_and_tagged_pdf_test.rs | 12 +- tests/links_and_outlines_test.rs | 31 +- tests/mcp_protocol_test.rs | 3 +- tests/mcp_tools_suite_test.rs | 137 +++- tests/multimodal_render_test.rs | 24 +- tests/scanned_document_detection_test.rs | 6 +- 10 files changed, 634 insertions(+), 383 deletions(-) create mode 100644 src/mcp/error.rs diff --git a/src/mcp/error.rs b/src/mcp/error.rs new file mode 100644 index 0000000..8eb6f8b --- /dev/null +++ b/src/mcp/error.rs @@ -0,0 +1,48 @@ +//! Tool-level errors for the DocuGraph MCP server. + +use std::fmt; + +use rmcp::ErrorData; +use rmcp::handler::server::tool::IntoCallToolResult; +use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock}; + +/// Error returned by a DocuGraph tool when the request is well formed but cannot be served: +/// an unknown document, section, page or attachment, or an empty page range. +/// +/// It is sent as a `CallToolResult` with `isError: true` and the message as text, so agents +/// and hooks can tell a failed lookup apart from a real answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolError { + message: String, +} + +impl ToolError { + /// Create a tool error with a message meant for the calling agent. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Message sent to the client as the text content of the error result. + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for ToolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ToolError {} + +impl IntoCallToolResult for ToolError { + fn into_call_tool_result(self) -> Result { + CallToolResult::error(vec![ContentBlock::text(self.message)]).into_call_tool_result() + } +} + +/// Result of a DocuGraph MCP tool: the response text, or a tool-level error. +pub type ToolResult = Result; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 37a8a97..32ed57a 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -3,8 +3,10 @@ //! Enforces that standard output (stdout) is dedicated exclusively //! to JSON-RPC protocol frames, while all diagnostic logs go to stderr. +pub mod error; pub mod server; pub mod tools; +pub use error::{ToolError, ToolResult}; pub use server::DocuGraphServer; pub use tools::*; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index c9d4b62..ee67701 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -9,12 +9,16 @@ use rmcp::{ use std::path::Path; use tracing::info; +use super::error::{ToolError, ToolResult}; use super::tools::*; use crate::document::model::{Document, PageKind, SectionNode}; use crate::multimodal::CachedPageRendererProxy; use crate::retrieval::{ContextBudget, ContextBuilder, HybridRetriever, HybridWeights}; use crate::storage::{DiskCache, DocumentStore}; +/// Maximum number of document ids or attachment names quoted in a "not found" error. +const MAX_LISTED_NAMES: usize = 20; + /// DocuGraph MCP server holding the tool router and shared document store. #[derive(Clone)] pub struct DocuGraphServer { @@ -114,6 +118,33 @@ impl DocuGraphServer { .filter_map(|m| self.store.get(&m.id)) .collect() } + + /// Fetch a document by id or content hash, or explain which ids exist. + fn require_document(&self, doc_id: &str) -> Result { + self.store + .get(doc_id) + .ok_or_else(|| self.document_not_found(doc_id)) + } + + /// Error for an unknown document id that lists the ids the agent can use instead. + fn document_not_found(&self, doc_id: &str) -> ToolError { + let mut ids: Vec = self + .store + .list_documents() + .into_iter() + .map(|m| m.id) + .collect(); + if ids.is_empty() { + return ToolError::new(format!( + "Document '{doc_id}' not found: no documents are indexed." + )); + } + ids.sort(); + ToolError::new(format!( + "Document '{doc_id}' not found. Available document ids: {}.", + quoted_list(&ids) + )) + } } #[tool_router(router = tool_router)] @@ -160,75 +191,50 @@ impl DocuGraphServer { name = "document_info", description = "Get structural metadata, page count, and section overview for an indexed document." )] - pub async fn document_info(&self, params: Parameters) -> String { - let doc_id = ¶ms.0.document_id; - if let Some(doc) = self.store.get(doc_id) { - let preview: Vec = doc - .sections - .iter() - .flat_map(|s| s.flatten()) - .take(25) - .map(|s| { - let indent = " ".repeat(s.level.saturating_sub(1) as usize); - format!( - "{indent}* {} (pp. {}-{})", - s.title, s.page_start, s.page_end - ) - }) - .collect(); + pub async fn document_info(&self, params: Parameters) -> ToolResult { + let doc = self.require_document(¶ms.0.document_id)?; + let preview: Vec = doc + .sections + .iter() + .flat_map(|s| s.flatten()) + .take(25) + .map(|s| { + let indent = " ".repeat(s.level.saturating_sub(1) as usize); + format!( + "{indent}* {} (pp. {}-{})", + s.title, s.page_start, s.page_end + ) + }) + .collect(); - let scan_warning = if doc.metadata.scanned_pages_count > 0 { - Some(format!( - "⚠️ {} de {} páginas parecen ser imágenes escaneadas sin capa de texto digital. Se recomienda OCR externo.", - doc.metadata.scanned_pages_count, doc.metadata.total_pages - )) - } else { - None - }; - - let info = DocumentInfoResult { - id: doc.id.to_string(), - title: doc.metadata.title.clone(), - total_pages: doc.metadata.total_pages, - total_sections: doc.total_sections() as u32, - content_hash: doc.metadata.content_hash.clone(), - sections_preview: preview, - is_encrypted: doc.metadata.is_encrypted, - untrusted_text_detected: doc.metadata.untrusted_text_detected, - scanned_pages_count: doc.metadata.scanned_pages_count, - scan_warning, - total_links: doc.metadata.total_links, - has_forms: doc.metadata.has_forms, - total_form_fields: doc.metadata.total_form_fields, - is_tagged: doc.metadata.is_tagged, - has_attachments: doc.metadata.has_attachments, - total_attachments: doc.metadata.total_attachments, - }; - serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string()) + let scan_warning = if doc.metadata.scanned_pages_count > 0 { + Some(format!( + "⚠️ {} de {} páginas parecen ser imágenes escaneadas sin capa de texto digital. Se recomienda OCR externo.", + doc.metadata.scanned_pages_count, doc.metadata.total_pages + )) } else { - serde_json::to_string_pretty(&DocumentInfoResult { - id: doc_id.clone(), - title: format!("Document Not Found: {}", doc_id), - total_pages: 0, - total_sections: 0, - content_hash: String::new(), - sections_preview: vec![ - "Document not found. Use 'document_list' to view available documents." - .to_string(), - ], - is_encrypted: false, - untrusted_text_detected: false, - scanned_pages_count: 0, - scan_warning: None, - total_links: 0, - has_forms: false, - total_form_fields: 0, - is_tagged: false, - has_attachments: false, - total_attachments: 0, - }) - .unwrap_or_else(|_| "{}".to_string()) - } + None + }; + + let info = DocumentInfoResult { + id: doc.id.to_string(), + title: doc.metadata.title.clone(), + total_pages: doc.metadata.total_pages, + total_sections: doc.total_sections() as u32, + content_hash: doc.metadata.content_hash.clone(), + sections_preview: preview, + is_encrypted: doc.metadata.is_encrypted, + untrusted_text_detected: doc.metadata.untrusted_text_detected, + scanned_pages_count: doc.metadata.scanned_pages_count, + scan_warning, + total_links: doc.metadata.total_links, + has_forms: doc.metadata.has_forms, + total_form_fields: doc.metadata.total_form_fields, + is_tagged: doc.metadata.is_tagged, + has_attachments: doc.metadata.has_attachments, + total_attachments: doc.metadata.total_attachments, + }; + Ok(serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())) } /// Retrieve the hierarchical table of contents (outline) of a document with page ranges. @@ -236,20 +242,16 @@ impl DocuGraphServer { name = "document_outline", description = "Get the hierarchical outline tree (H1, H2, H3) with exact page ranges and section IDs." )] - pub async fn document_outline(&self, params: Parameters) -> String { - let doc_id = ¶ms.0.document_id; + pub async fn document_outline(&self, params: Parameters) -> ToolResult { + let doc = self.require_document(¶ms.0.document_id)?; let max_depth = params.0.max_depth.unwrap_or(3); - if let Some(doc) = self.store.get(doc_id) { - let tree: Vec = doc - .sections - .iter() - .filter_map(|s| map_outline_node(s, 1, max_depth)) - .collect(); - serde_json::to_string_pretty(&tree).unwrap_or_else(|_| "[]".to_string()) - } else { - format!("Error: Document '{doc_id}' not found.") - } + let tree: Vec = doc + .sections + .iter() + .filter_map(|s| map_outline_node(s, 1, max_depth)) + .collect(); + Ok(serde_json::to_string_pretty(&tree).unwrap_or_else(|_| "[]".to_string())) } /// Perform fast Okapi BM25 keyword search over document sections and pages. @@ -257,16 +259,16 @@ impl DocuGraphServer { name = "document_search", description = "Fast BM25 keyword search across sections and pages. Returns ranked snippets with citations." )] - pub async fn document_search(&self, params: Parameters) -> String { + pub async fn document_search(&self, params: Parameters) -> ToolResult { let limit = params.0.limit.unwrap_or(5); let docs = self.get_documents(params.0.document_id.as_deref()); if docs.is_empty() { - return "No documents available for search.".to_string(); + return Ok("No documents available for search.".to_string()); } let bm25 = crate::retrieval::Bm25Index::build_from_documents(&docs, None); let hits = bm25.search(¶ms.0.query, limit); - serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string()) + Ok(serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string())) } /// Perform hybrid search (BM25 + Semantic Cosine + Structural Boost) with configurable weights. @@ -277,11 +279,11 @@ impl DocuGraphServer { pub async fn document_search_hybrid( &self, params: Parameters, - ) -> String { + ) -> ToolResult { let limit = params.0.limit.unwrap_or(5); let docs = self.get_documents(params.0.document_id.as_deref()); if docs.is_empty() { - return "No documents available for search.".to_string(); + return Ok("No documents available for search.".to_string()); } let weights = HybridWeights { @@ -292,7 +294,7 @@ impl DocuGraphServer { let retriever = HybridRetriever::build(&docs, None, Some(weights)); let hits = retriever.search(¶ms.0.query, limit); - serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string()) + Ok(serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string())) } /// Retrieve the full content of a specific section with optional parent context and token budgeting. @@ -303,7 +305,7 @@ impl DocuGraphServer { pub async fn document_get_section( &self, params: Parameters, - ) -> String { + ) -> ToolResult { let doc_id = ¶ms.0.document_id; let section_id = ¶ms.0.section_id; let include_parent = params.0.include_parent.unwrap_or(true); @@ -313,14 +315,14 @@ impl DocuGraphServer { compact: true, }; - if let Some(doc) = self.store.get(doc_id) { - match ContextBuilder::expand_section_context(&doc, section_id, include_parent, budget) { - Some(content) => content, - None => format!("Error: Section '{section_id}' not found in document '{doc_id}'."), - } - } else { - format!("Error: Document '{doc_id}' not found.") - } + let doc = self.require_document(doc_id)?; + ContextBuilder::expand_section_context(&doc, section_id, include_parent, budget).ok_or_else( + || { + ToolError::new(format!( + "Section '{section_id}' not found in document '{doc_id}'. Use document_outline to list section ids." + )) + }, + ) } /// Retrieve broader surrounding conceptual context for a topic or query across the document graph. @@ -331,11 +333,11 @@ impl DocuGraphServer { pub async fn document_get_context( &self, params: Parameters, - ) -> String { + ) -> ToolResult { let query = ¶ms.0.query; let docs = self.get_documents(params.0.document_id.as_deref()); if docs.is_empty() { - return "No documents available for context expansion.".to_string(); + return Ok("No documents available for context expansion.".to_string()); } let budget = ContextBudget { @@ -346,7 +348,9 @@ impl DocuGraphServer { let retriever = HybridRetriever::build(&docs, None, None); let hits = retriever.search(query, budget.max_chunks * 2); - ContextBuilder::build_conceptual_context(query, &hits, &docs, budget) + Ok(ContextBuilder::build_conceptual_context( + query, &hits, &docs, budget, + )) } /// Retrieve compact evidence snippets with guaranteed citation provenance for LLM reasoning. @@ -357,11 +361,11 @@ impl DocuGraphServer { pub async fn document_get_evidence( &self, params: Parameters, - ) -> String { + ) -> ToolResult { let query = ¶ms.0.query; let docs = self.get_documents(params.0.document_id.as_deref()); if docs.is_empty() { - return "No documents available for evidence collection.".to_string(); + return Ok("No documents available for evidence collection.".to_string()); } let budget = ContextBudget { @@ -373,7 +377,7 @@ impl DocuGraphServer { let retriever = HybridRetriever::build(&docs, None, None); let hits = retriever.search(query, budget.max_chunks * 2); let bundle = ContextBuilder::build_evidence(query, &hits, budget); - bundle.to_markdown() + Ok(bundle.to_markdown()) } /// Read raw text from a specific page range with a character budget. @@ -381,49 +385,48 @@ impl DocuGraphServer { name = "document_read_pages", description = "Read sequential pages directly from a document with strict character bounds." )] - pub async fn document_read_pages(&self, params: Parameters) -> String { - let doc_id = ¶ms.0.document_id; + pub async fn document_read_pages( + &self, + params: Parameters, + ) -> ToolResult { let max_chars = params.0.max_chars.unwrap_or(8000); - - if let Some(doc) = self.store.get(doc_id) { - let mut out = String::new(); - out.push_str(&format!( - "# Lectura de Páginas: {} (pp. {}-{})\n\n", - doc.metadata.title, params.0.page_start, params.0.page_end - )); - - let mut chars_count = 0; - for p in params.0.page_start..=params.0.page_end { - if let Some(page) = doc.get_page(p) { - let page_header = if page.kind == PageKind::ScannedImage { - format!( - "--- Página {} [📷 Imagen Escaneada / Sin Capa de Texto] ---\n", - p - ) - } else if page.untrusted_text_detected { - format!("--- Página {} [⚠️ Untrusted Hidden Text Detected] ---\n", p) - } else if page.kind == PageKind::Empty { - format!("--- Página {} [Página en Blanco] ---\n", p) - } else { - format!("--- Página {} ---\n", p) - }; - if chars_count + page_header.len() + page.text.len() > max_chars { - let remaining = max_chars.saturating_sub(chars_count + page_header.len()); - out.push_str(&page_header); - out.push_str(&page.text.chars().take(remaining).collect::()); - out.push_str("\n\n*(Límite de caracteres alcanzado)*\n"); - break; - } + let doc = self.require_document(¶ms.0.document_id)?; + + let mut out = String::new(); + out.push_str(&format!( + "# Lectura de Páginas: {} (pp. {}-{})\n\n", + doc.metadata.title, params.0.page_start, params.0.page_end + )); + + let mut chars_count = 0; + for p in params.0.page_start..=params.0.page_end { + if let Some(page) = doc.get_page(p) { + let page_header = if page.kind == PageKind::ScannedImage { + format!( + "--- Página {} [📷 Imagen Escaneada / Sin Capa de Texto] ---\n", + p + ) + } else if page.untrusted_text_detected { + format!("--- Página {} [⚠️ Untrusted Hidden Text Detected] ---\n", p) + } else if page.kind == PageKind::Empty { + format!("--- Página {} [Página en Blanco] ---\n", p) + } else { + format!("--- Página {} ---\n", p) + }; + if chars_count + page_header.len() + page.text.len() > max_chars { + let remaining = max_chars.saturating_sub(chars_count + page_header.len()); out.push_str(&page_header); - out.push_str(&page.text); - out.push_str("\n\n"); - chars_count += page_header.len() + page.text.len(); + out.push_str(&page.text.chars().take(remaining).collect::()); + out.push_str("\n\n*(Límite de caracteres alcanzado)*\n"); + break; } + out.push_str(&page_header); + out.push_str(&page.text); + out.push_str("\n\n"); + chars_count += page_header.len() + page.text.len(); } - out - } else { - format!("Error: Document '{doc_id}' not found.") } + Ok(out) } /// Render a specific document page to a high-resolution PNG image for visual multimodal inspection. @@ -431,42 +434,34 @@ impl DocuGraphServer { name = "document_render_page", description = "Render a specific document page to a high-resolution PNG image for visual inspection (diagrams, complex charts, scans) by Multimodal LLMs." )] - pub async fn document_render_page(&self, params: Parameters) -> String { + pub async fn document_render_page(&self, params: Parameters) -> ToolResult { let doc_id = ¶ms.0.document_id; let page_num = params.0.page_number; let max_width = params.0.max_width.unwrap_or(1024); - if let Some(doc) = self.store.get(doc_id) { - match self.renderer.render_document_page(&doc, page_num, max_width) { - Ok(rendered) => { - let data_uri = format!("data:image/png;base64,{}", rendered.base64_data); - let res = RenderPageResult { - document_id: doc_id.clone(), - page_number: rendered.page_number, - width: rendered.width, - height: rendered.height, - mime_type: "image/png".to_string(), - base64_image: rendered.base64_data, - data_uri, - from_cache: rendered.from_cache, - }; - serde_json::to_string_pretty(&res).unwrap_or_else(|_| "{}".to_string()) - } - Err(e) => serde_json::json!({ - "error": format!("Failed to render page {page_num} of document '{doc_id}': {e}"), - "document_id": doc_id, - "page_number": page_num, - }) - .to_string(), - } - } else { - serde_json::json!({ - "error": format!("Document '{doc_id}' not found."), - "document_id": doc_id, - "page_number": page_num, - }) - .to_string() - } + let doc = self.require_document(doc_id)?; + check_page(&doc, page_num)?; + let rendered = self + .renderer + .render_document_page(&doc, page_num, max_width) + .map_err(|e| { + ToolError::new(format!( + "Failed to render page {page_num} of document '{doc_id}': {e}" + )) + })?; + + let data_uri = format!("data:image/png;base64,{}", rendered.base64_data); + let res = RenderPageResult { + document_id: doc_id.clone(), + page_number: rendered.page_number, + width: rendered.width, + height: rendered.height, + mime_type: "image/png".to_string(), + base64_image: rendered.base64_data, + data_uri, + from_cache: rendered.from_cache, + }; + Ok(serde_json::to_string_pretty(&res).unwrap_or_else(|_| "{}".to_string())) } /// Extract hyperlinks and internal cross-references from a document. @@ -474,66 +469,64 @@ impl DocuGraphServer { name = "document_get_links", description = "Extract hyperlinks and internal cross-references from a document with exact page numbers, URLs, and coordinates." )] - pub async fn document_get_links(&self, params: Parameters) -> String { + pub async fn document_get_links( + &self, + params: Parameters, + ) -> ToolResult { let doc_id = ¶ms.0.document_id; let page_filter = params.0.page; let kind_filter = params.0.kind.as_deref().unwrap_or("all").to_lowercase(); - if let Some(doc) = self.store.get(doc_id) { - let mut results = Vec::new(); + let doc = self.require_document(doc_id)?; + if let Some(p) = page_filter { + check_page(&doc, p)?; + } - let target_pages: Vec<&crate::document::Page> = if let Some(p) = page_filter { - doc.get_page(p).into_iter().collect() - } else { - doc.pages.iter().collect() - }; - - for page in target_pages { - for link in &page.links { - let kind_str = match &link.target { - crate::document::LinkTarget::Uri(_) => "external", - crate::document::LinkTarget::InternalPage(_) => "internal", - crate::document::LinkTarget::Named(_) => "named", - }; - - let matches_kind = match kind_filter.as_str() { - "external" => link.is_external(), - "internal" => link.is_internal(), - _ => true, - }; - - if matches_kind { - results.push(DocumentLinkResult { - page_number: link.page_number, - kind: kind_str.to_string(), - uri: link.uri.clone(), - target_page: link.target_page, - named_target: match &link.target { - crate::document::LinkTarget::Named(name) => Some(name.clone()), - _ => None, - }, - rect: link.rect, - }); - } + let mut results = Vec::new(); + + let target_pages: Vec<&crate::document::Page> = if let Some(p) = page_filter { + doc.get_page(p).into_iter().collect() + } else { + doc.pages.iter().collect() + }; + + for page in target_pages { + for link in &page.links { + let kind_str = match &link.target { + crate::document::LinkTarget::Uri(_) => "external", + crate::document::LinkTarget::InternalPage(_) => "internal", + crate::document::LinkTarget::Named(_) => "named", + }; + + let matches_kind = match kind_filter.as_str() { + "external" => link.is_external(), + "internal" => link.is_internal(), + _ => true, + }; + + if matches_kind { + results.push(DocumentLinkResult { + page_number: link.page_number, + kind: kind_str.to_string(), + uri: link.uri.clone(), + target_page: link.target_page, + named_target: match &link.target { + crate::document::LinkTarget::Named(name) => Some(name.clone()), + _ => None, + }, + rect: link.rect, + }); } } + } - let response = DocumentGetLinksResult { - document_id: doc_id.clone(), - total_links: results.len(), - links: results, - }; + let response = DocumentGetLinksResult { + document_id: doc_id.clone(), + total_links: results.len(), + links: results, + }; - serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string()) - } else { - serde_json::json!({ - "error": format!("Document '{doc_id}' not found."), - "document_id": doc_id, - "total_links": 0, - "links": [] - }) - .to_string() - } + Ok(serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())) } /// Extract interactive form fields (AcroForms) from a document. @@ -541,60 +534,58 @@ impl DocuGraphServer { name = "document_get_forms", description = "Extract interactive AcroForm fields (text inputs, checkboxes, radio buttons, dropdowns) with names, values, and page coordinates." )] - pub async fn document_get_forms(&self, params: Parameters) -> String { + pub async fn document_get_forms( + &self, + params: Parameters, + ) -> ToolResult { let doc_id = ¶ms.0.document_id; let page_filter = params.0.page; let filled_only = params.0.filled_only.unwrap_or(false); - if let Some(doc) = self.store.get(doc_id) { - let mut results = Vec::new(); + let doc = self.require_document(doc_id)?; + if let Some(p) = page_filter { + check_page(&doc, p)?; + } - for field in &doc.forms { - if page_filter.is_some_and(|target_p| field.page_number != Some(target_p)) { - continue; - } + let mut results = Vec::new(); - if filled_only - && field - .value - .as_deref() - .map(|v| v.trim().is_empty()) - .unwrap_or(true) - { - continue; - } - - let type_str = field.field_type.as_str(); - - results.push(FormFieldResult { - name: field.name.clone(), - fully_qualified_name: field.fully_qualified_name.clone(), - field_type: type_str.to_string(), - value: field.value.clone(), - default_value: field.default_value.clone(), - read_only: field.read_only, - required: field.required, - page_number: field.page_number, - rect: field.rect, - }); + for field in &doc.forms { + if page_filter.is_some_and(|target_p| field.page_number != Some(target_p)) { + continue; } - let response = DocumentGetFormsResult { - document_id: doc_id.clone(), - total_fields: results.len(), - fields: results, - }; + if filled_only + && field + .value + .as_deref() + .map(|v| v.trim().is_empty()) + .unwrap_or(true) + { + continue; + } - serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string()) - } else { - serde_json::json!({ - "error": format!("Document '{doc_id}' not found."), - "document_id": doc_id, - "total_fields": 0, - "fields": [] - }) - .to_string() + let type_str = field.field_type.as_str(); + + results.push(FormFieldResult { + name: field.name.clone(), + fully_qualified_name: field.fully_qualified_name.clone(), + field_type: type_str.to_string(), + value: field.value.clone(), + default_value: field.default_value.clone(), + read_only: field.read_only, + required: field.required, + page_number: field.page_number, + rect: field.rect, + }); } + + let response = DocumentGetFormsResult { + document_id: doc_id.clone(), + total_fields: results.len(), + fields: results, + }; + + Ok(serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())) } /// Retrieve metadata for all embedded files and attachments inside a document. @@ -605,40 +596,31 @@ impl DocuGraphServer { pub async fn document_get_attachments( &self, params: Parameters, - ) -> String { + ) -> ToolResult { let doc_id = ¶ms.0.document_id; - if let Some(doc) = self.store.get(doc_id) { - let attachments: Vec = doc - .attachments - .iter() - .map(|att| AttachmentSummaryResult { - id: att.id.clone(), - filename: att.filename.clone(), - description: att.description.clone(), - mime_type: att.mime_type.clone(), - size_bytes: att.size_bytes, - checksum_md5: att.checksum_md5.clone(), - mod_date: att.mod_date.clone(), - is_text: att.is_text, - page_number: att.page_number, - }) - .collect(); - - let res = DocumentGetAttachmentsResult { - document_id: doc_id.clone(), - total_attachments: attachments.len(), - attachments, - }; - serde_json::to_string_pretty(&res).unwrap_or_else(|_| "{}".to_string()) - } else { - serde_json::json!({ - "error": format!("Document '{doc_id}' not found."), - "document_id": doc_id, - "total_attachments": 0, - "attachments": [] + let doc = self.require_document(doc_id)?; + let attachments: Vec = doc + .attachments + .iter() + .map(|att| AttachmentSummaryResult { + id: att.id.clone(), + filename: att.filename.clone(), + description: att.description.clone(), + mime_type: att.mime_type.clone(), + size_bytes: att.size_bytes, + checksum_md5: att.checksum_md5.clone(), + mod_date: att.mod_date.clone(), + is_text: att.is_text, + page_number: att.page_number, }) - .to_string() - } + .collect(); + + let res = DocumentGetAttachmentsResult { + document_id: doc_id.clone(), + total_attachments: attachments.len(), + attachments, + }; + Ok(serde_json::to_string_pretty(&res).unwrap_or_else(|_| "{}".to_string())) } /// Read and decode the content of an embedded file attachment (e.g. ZUGFeRD XML, CSV, dataset). @@ -649,71 +631,111 @@ impl DocuGraphServer { pub async fn document_read_attachment( &self, params: Parameters, - ) -> String { + ) -> ToolResult { let doc_id = ¶ms.0.document_id; let name_or_id = ¶ms.0.name_or_id; let max_bytes = params.0.max_bytes.unwrap_or(524_288); // 512 KB default limit - if let Some(doc) = self.store.get(doc_id) { - if let Some(att) = doc.get_attachment(name_or_id) { - let force_base64 = params.0.encoding.as_deref() == Some("base64"); - let should_be_text = !force_base64 && att.is_text; - - let (encoded_content, truncated) = if att.data.len() > max_bytes { - let slice = &att.data[..max_bytes]; - if should_be_text { - let text = String::from_utf8_lossy(slice).to_string(); - (text, true) - } else { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(slice); - (b64, true) - } - } else if should_be_text { - let text = String::from_utf8_lossy(&att.data).to_string(); - (text, false) - } else { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&att.data); - (b64, false) - }; + let doc = self.require_document(doc_id)?; + let att = doc + .get_attachment(name_or_id) + .ok_or_else(|| attachment_not_found(&doc, name_or_id))?; - let res = DocumentReadAttachmentResult { - document_id: doc_id.clone(), - filename: att.filename.clone(), - mime_type: att.mime_type.clone(), - size_bytes: att.size_bytes, - encoding: if should_be_text { - "text".to_string() - } else { - "base64".to_string() - }, - content: encoded_content, - truncated, - }; + let force_base64 = params.0.encoding.as_deref() == Some("base64"); + let should_be_text = !force_base64 && att.is_text; - serde_json::to_string_pretty(&res).unwrap_or_else(|_| "{}".to_string()) + let (encoded_content, truncated) = if att.data.len() > max_bytes { + let slice = &att.data[..max_bytes]; + if should_be_text { + let text = String::from_utf8_lossy(slice).to_string(); + (text, true) } else { - serde_json::json!({ - "error": format!("Attachment '{name_or_id}' not found in document '{doc_id}'."), - "document_id": doc_id, - "filename": name_or_id, - "size_bytes": 0, - "content": "" - }) - .to_string() + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(slice); + (b64, true) } + } else if should_be_text { + let text = String::from_utf8_lossy(&att.data).to_string(); + (text, false) } else { - serde_json::json!({ - "error": format!("Document '{doc_id}' not found."), - "document_id": doc_id, - "filename": name_or_id, - "size_bytes": 0, - "content": "" - }) - .to_string() - } + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&att.data); + (b64, false) + }; + + let res = DocumentReadAttachmentResult { + document_id: doc_id.clone(), + filename: att.filename.clone(), + mime_type: att.mime_type.clone(), + size_bytes: att.size_bytes, + encoding: if should_be_text { + "text".to_string() + } else { + "base64".to_string() + }, + content: encoded_content, + truncated, + }; + + Ok(serde_json::to_string_pretty(&res).unwrap_or_else(|_| "{}".to_string())) + } +} + +/// Highest page number of a document, taken from its metadata or its extracted pages. +fn page_count(doc: &Document) -> u32 { + doc.pages + .iter() + .map(|p| p.page_number) + .max() + .unwrap_or(0) + .max(doc.metadata.total_pages) +} + +/// Error unless `page` is a valid 1-based page number of `doc`. +fn check_page(doc: &Document, page: u32) -> Result<(), ToolError> { + let total = page_count(doc); + if total == 0 { + return Err(ToolError::new(format!( + "Page {page} does not exist: document '{}' has no pages.", + doc.id + ))); + } + if page == 0 || page > total { + return Err(ToolError::new(format!( + "Page {page} does not exist in document '{}': valid pages are 1-{total}.", + doc.id + ))); + } + Ok(()) +} + +/// Error for an unknown attachment that lists the attachments the document does have. +fn attachment_not_found(doc: &Document, name_or_id: &str) -> ToolError { + if doc.attachments.is_empty() { + return ToolError::new(format!( + "Attachment '{name_or_id}' not found: document '{}' has no attachments.", + doc.id + )); + } + let names: Vec = doc.attachments.iter().map(|a| a.filename.clone()).collect(); + ToolError::new(format!( + "Attachment '{name_or_id}' not found in document '{}'. Available attachments: {}.", + doc.id, + quoted_list(&names) + )) +} + +/// Quote names for an error message, keeping at most [`MAX_LISTED_NAMES`] of them. +fn quoted_list(names: &[String]) -> String { + let mut listed: Vec = names + .iter() + .take(MAX_LISTED_NAMES) + .map(|name| format!("'{name}'")) + .collect(); + if names.len() > MAX_LISTED_NAMES { + listed.push(format!("... ({} more)", names.len() - MAX_LISTED_NAMES)); } + listed.join(", ") } fn map_outline_node( diff --git a/tests/attachments_and_embedded_files_test.rs b/tests/attachments_and_embedded_files_test.rs index ed2239d..51cc601 100644 --- a/tests/attachments_and_embedded_files_test.rs +++ b/tests/attachments_and_embedded_files_test.rs @@ -357,7 +357,8 @@ async fn test_mcp_document_get_attachments_and_read_attachment() { .document_get_attachments(Parameters(DocumentGetAttachmentsParams { document_id: "doc_with_attachments".to_string(), })) - .await; + .await + .expect("tool call should succeed"); let list_res: DocumentGetAttachmentsResult = serde_json::from_str(&list_json).expect("parse list JSON"); @@ -374,7 +375,8 @@ async fn test_mcp_document_get_attachments_and_read_attachment() { max_bytes: None, encoding: None, })) - .await; + .await + .expect("tool call should succeed"); let read_xml_res: DocumentReadAttachmentResult = serde_json::from_str(&read_xml_json).expect("parse read XML JSON"); @@ -391,7 +393,8 @@ async fn test_mcp_document_get_attachments_and_read_attachment() { max_bytes: None, encoding: None, })) - .await; + .await + .expect("tool call should succeed"); let read_bin_res: DocumentReadAttachmentResult = serde_json::from_str(&read_bin_json).expect("parse read binary JSON"); @@ -407,7 +410,8 @@ async fn test_mcp_document_get_attachments_and_read_attachment() { max_bytes: Some(10), encoding: None, })) - .await; + .await + .expect("tool call should succeed"); let read_trunc_res: DocumentReadAttachmentResult = serde_json::from_str(&read_trunc_json).expect("parse read truncated JSON"); @@ -419,9 +423,23 @@ async fn test_mcp_document_get_attachments_and_read_attachment() { .document_info(Parameters(DocumentInfoParams { document_id: "doc_with_attachments".to_string(), })) - .await; + .await + .expect("tool call should succeed"); let info_res: DocumentInfoResult = serde_json::from_str(&info_json).expect("parse info JSON"); assert!(info_res.has_attachments); assert_eq!(info_res.total_attachments, 2); + + // 6. Unknown attachment: tool error that names the attachments that exist + let err = server + .document_read_attachment(Parameters(DocumentReadAttachmentParams { + document_id: "doc_with_attachments".to_string(), + name_or_id: "missing.csv".to_string(), + max_bytes: None, + encoding: None, + })) + .await + .expect_err("unknown attachment must be a tool error"); + assert!(err.message().contains("'missing.csv' not found"), "{err}"); + assert!(err.message().contains("'factur-x.xml'"), "{err}"); } diff --git a/tests/forms_and_tagged_pdf_test.rs b/tests/forms_and_tagged_pdf_test.rs index fbb7b92..9e40d1c 100644 --- a/tests/forms_and_tagged_pdf_test.rs +++ b/tests/forms_and_tagged_pdf_test.rs @@ -454,7 +454,8 @@ async fn test_mcp_document_get_forms_and_filtering() { page: None, filled_only: None, })) - .await; + .await + .expect("tool call should succeed"); let res: DocumentGetFormsResult = serde_json::from_str(&json_str).expect("parse JSON"); assert_eq!(res.total_fields, 3); @@ -467,7 +468,8 @@ async fn test_mcp_document_get_forms_and_filtering() { page: Some(1), filled_only: None, })) - .await; + .await + .expect("tool call should succeed"); let res_p1: DocumentGetFormsResult = serde_json::from_str(&json_p1).expect("parse JSON"); assert_eq!(res_p1.total_fields, 2); @@ -480,7 +482,8 @@ async fn test_mcp_document_get_forms_and_filtering() { page: None, filled_only: Some(true), })) - .await; + .await + .expect("tool call should succeed"); let res_filled: DocumentGetFormsResult = serde_json::from_str(&json_filled).expect("parse JSON"); @@ -493,7 +496,8 @@ async fn test_mcp_document_get_forms_and_filtering() { .document_info(Parameters(DocumentInfoParams { document_id: "doc_with_forms".to_string(), })) - .await; + .await + .expect("tool call should succeed"); let info_res: DocumentInfoResult = serde_json::from_str(&info_str).expect("parse info JSON"); assert_eq!(info_res.title, "Tax Form 1040"); diff --git a/tests/links_and_outlines_test.rs b/tests/links_and_outlines_test.rs index d0f647d..8bfa8f5 100644 --- a/tests/links_and_outlines_test.rs +++ b/tests/links_and_outlines_test.rs @@ -355,7 +355,8 @@ async fn test_mcp_tool_document_get_links() { page: None, kind: Some("all".to_string()), })) - .await; + .await + .expect("tool call should succeed"); let res: DocumentGetLinksResult = serde_json::from_str(&all_links_json).expect("valid JSON result"); @@ -370,7 +371,8 @@ async fn test_mcp_tool_document_get_links() { page: None, kind: Some("external".to_string()), })) - .await; + .await + .expect("tool call should succeed"); let ext_res: DocumentGetLinksResult = serde_json::from_str(&ext_json).expect("valid JSON result"); assert_eq!(ext_res.total_links, 1); @@ -387,7 +389,8 @@ async fn test_mcp_tool_document_get_links() { page: None, kind: Some("internal".to_string()), })) - .await; + .await + .expect("tool call should succeed"); let int_res: DocumentGetLinksResult = serde_json::from_str(&int_json).expect("valid JSON result"); assert_eq!(int_res.total_links, 1); @@ -401,7 +404,8 @@ async fn test_mcp_tool_document_get_links() { page: Some(2), kind: None, })) - .await; + .await + .expect("tool call should succeed"); let p2_res: DocumentGetLinksResult = serde_json::from_str(&p2_json).expect("valid JSON result"); assert_eq!(p2_res.total_links, 1); assert_eq!(p2_res.links[0].page_number, 2); @@ -409,12 +413,25 @@ async fn test_mcp_tool_document_get_links() { assert_eq!(p2_res.links[0].named_target.as_deref(), Some("appendix-b")); // 5. Query non-existent document - let not_found_json = server + let err = server .document_get_links(Parameters(DocumentGetLinksParams { document_id: "non-existent".to_string(), page: None, kind: None, })) - .await; - assert!(not_found_json.contains("not found")); + .await + .expect_err("unknown document must be a tool error"); + assert!(err.message().contains("not found"), "{err}"); + assert!(err.message().contains("'links-demo-doc'"), "{err}"); + + // 6. Query a page the document does not have + let err = server + .document_get_links(Parameters(DocumentGetLinksParams { + document_id: "links-demo-doc".to_string(), + page: Some(4), + kind: None, + })) + .await + .expect_err("missing page must be a tool error"); + assert!(err.message().contains("valid pages are 1-3"), "{err}"); } diff --git a/tests/mcp_protocol_test.rs b/tests/mcp_protocol_test.rs index 89e7f04..54c60f8 100644 --- a/tests/mcp_protocol_test.rs +++ b/tests/mcp_protocol_test.rs @@ -100,7 +100,8 @@ async fn test_document_info_tool() { .document_info(Parameters(DocumentInfoParams { document_id: "test_doc_gof".to_string(), })) - .await; + .await + .expect("tool call should succeed"); let parsed: serde_json::Value = serde_json::from_str(&info_json).expect("valid JSON object"); assert_eq!(parsed["id"], "test_doc_gof"); diff --git a/tests/mcp_tools_suite_test.rs b/tests/mcp_tools_suite_test.rs index c29845c..072ace8 100644 --- a/tests/mcp_tools_suite_test.rs +++ b/tests/mcp_tools_suite_test.rs @@ -1,7 +1,9 @@ use docugraph::document::model::{Document, DocumentMetadata, Page, SectionNode}; use docugraph::mcp::{DocuGraphServer, tools::*}; use docugraph::storage::DocumentStore; +use rmcp::handler::server::tool::IntoCallToolResult; use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::CallToolResponse; fn create_test_server() -> DocuGraphServer { let store = DocumentStore::new(None); // Pure in-memory for testing @@ -69,7 +71,8 @@ async fn test_mcp_document_outline() { document_id: "git-guide".to_string(), max_depth: Some(2), })) - .await; + .await + .expect("tool call should succeed"); let tree: serde_json::Value = serde_json::from_str(&outline_json).expect("valid JSON tree"); assert!(tree.is_array()); @@ -87,7 +90,8 @@ async fn test_mcp_document_search_bm25() { document_id: Some("git-guide".to_string()), limit: Some(3), })) - .await; + .await + .expect("tool call should succeed"); let hits: serde_json::Value = serde_json::from_str(&resp).expect("valid JSON hits"); assert!(hits.is_array()); @@ -107,7 +111,8 @@ async fn test_mcp_document_search_hybrid() { semantic_weight: Some(0.2), structural_weight: Some(0.2), })) - .await; + .await + .expect("tool call should succeed"); let hits: serde_json::Value = serde_json::from_str(&resp).expect("valid JSON hybrid hits"); assert!(hits.is_array()); @@ -125,7 +130,8 @@ async fn test_mcp_document_get_section() { include_parent: Some(true), max_tokens: Some(500), })) - .await; + .await + .expect("tool call should succeed"); assert!(content.contains("1.1 Ramas Locales")); assert!(content.contains("Sección Padre")); @@ -142,7 +148,8 @@ async fn test_mcp_document_get_evidence() { max_tokens: Some(500), max_items: Some(2), })) - .await; + .await + .expect("tool call should succeed"); assert!(evidence_md.contains("Evidencia Recuperada")); assert!(evidence_md.contains("[Doc: git-guide")); @@ -158,7 +165,8 @@ async fn test_mcp_document_read_pages() { page_end: 3, max_chars: Some(1000), })) - .await; + .await + .expect("tool call should succeed"); assert!(pages_md.contains("Página 2")); assert!(pages_md.contains("Página 3")); @@ -175,8 +183,123 @@ async fn test_mcp_document_get_context() { max_tokens: Some(600), max_chunks: Some(2), })) - .await; + .await + .expect("tool call should succeed"); assert!(context_md.contains("Contexto Conceptual")); assert!(context_md.contains("1.1 Ramas Locales") || context_md.contains("git branch")); } + +#[tokio::test] +async fn test_mcp_domain_errors_are_tool_errors() { + let server = create_test_server(); + + let err = server + .document_info(Parameters(DocumentInfoParams { + document_id: "missing-doc".to_string(), + })) + .await + .expect_err("unknown document must be a tool error"); + assert!(err.message().contains("'missing-doc' not found"), "{err}"); + assert!( + err.message().contains("'git-guide'"), + "error should list the available ids: {err}" + ); + + let err = server + .document_outline(Parameters(DocumentOutlineParams { + document_id: "missing-doc".to_string(), + max_depth: None, + })) + .await + .expect_err("unknown document must be a tool error"); + assert!(err.message().contains("not found"), "{err}"); + + let err = server + .document_get_section(Parameters(DocumentGetSectionParams { + document_id: "git-guide".to_string(), + section_id: "no-such-section".to_string(), + include_parent: None, + max_tokens: None, + })) + .await + .expect_err("unknown section must be a tool error"); + assert!( + err.message() + .contains("Section 'no-such-section' not found"), + "{err}" + ); + + let err = server + .document_read_pages(Parameters(DocumentReadPagesParams { + document_id: "missing-doc".to_string(), + page_start: 1, + page_end: 1, + max_chars: None, + })) + .await + .expect_err("unknown document must be a tool error"); + assert!(err.message().contains("not found"), "{err}"); + + let err = server + .document_render_page(Parameters(RenderPageParams { + document_id: "git-guide".to_string(), + page_number: 99, + max_width: None, + })) + .await + .expect_err("missing page must be a tool error"); + assert!(err.message().contains("Page 99 does not exist"), "{err}"); + + let err = server + .document_get_forms(Parameters(DocumentGetFormsParams { + document_id: "git-guide".to_string(), + page: Some(0), + filled_only: None, + })) + .await + .expect_err("page 0 must be a tool error"); + assert!(err.message().contains("Page 0 does not exist"), "{err}"); + + let err = server + .document_get_attachments(Parameters(DocumentGetAttachmentsParams { + document_id: "missing-doc".to_string(), + })) + .await + .expect_err("unknown document must be a tool error"); + assert!(err.message().contains("not found"), "{err}"); + + let err = server + .document_read_attachment(Parameters(DocumentReadAttachmentParams { + document_id: "git-guide".to_string(), + name_or_id: "invoice.xml".to_string(), + max_bytes: None, + encoding: None, + })) + .await + .expect_err("unknown attachment must be a tool error"); + assert!(err.message().contains("has no attachments"), "{err}"); +} + +#[tokio::test] +async fn test_mcp_tool_error_is_sent_with_is_error() { + let server = create_test_server(); + let result = server + .document_info(Parameters(DocumentInfoParams { + document_id: "missing-doc".to_string(), + })) + .await; + + let response = result + .into_call_tool_result() + .expect("a domain error is a tool result, not a JSON-RPC error"); + let CallToolResponse::Complete(call_result) = response else { + panic!("expected a complete tool result"); + }; + assert_eq!(call_result.is_error, Some(true)); + + let wire = serde_json::to_value(&call_result).expect("serialize tool result"); + assert_eq!(wire["isError"], true); + let text = wire["content"][0]["text"].as_str().expect("text content"); + assert!(text.contains("'missing-doc' not found"), "{text}"); +} diff --git a/tests/multimodal_render_test.rs b/tests/multimodal_render_test.rs index e4fc9e4..90c159e 100644 --- a/tests/multimodal_render_test.rs +++ b/tests/multimodal_render_test.rs @@ -151,7 +151,10 @@ async fn test_mcp_document_render_page_tool() { page_number: 1, max_width: Some(800), }; - let json_resp = server.document_render_page(Parameters(params)).await; + let json_resp = server + .document_render_page(Parameters(params)) + .await + .expect("render of an existing page should succeed"); let parsed: serde_json::Value = serde_json::from_str(&json_resp).expect("Valid JSON response from tool"); @@ -178,11 +181,22 @@ async fn test_mcp_document_render_page_tool() { page_number: 1, max_width: None, }; - let err_json = server + let err = server .document_render_page(Parameters(invalid_params)) - .await; - let err_parsed: serde_json::Value = serde_json::from_str(&err_json).expect("Valid error JSON"); - assert!(err_parsed["error"].as_str().unwrap().contains("not found")); + .await + .expect_err("unknown document must be a tool error"); + assert!(err.message().contains("not found"), "{err}"); + + // Test requesting a page the document does not have + let err = server + .document_render_page(Parameters(RenderPageParams { + document_id: "multimodal-doc".to_string(), + page_number: 2, + max_width: None, + })) + .await + .expect_err("missing page must be a tool error"); + assert!(err.message().contains("Page 2 does not exist"), "{err}"); } #[test] diff --git a/tests/scanned_document_detection_test.rs b/tests/scanned_document_detection_test.rs index 0d2aa32..0456a74 100644 --- a/tests/scanned_document_detection_test.rs +++ b/tests/scanned_document_detection_test.rs @@ -237,7 +237,8 @@ async fn test_mcp_server_scanned_warnings_in_tools() { .document_info(Parameters(DocumentInfoParams { document_id: "doc-with-scans".to_string(), })) - .await; + .await + .expect("tool call should succeed"); let info: DocumentInfoResult = serde_json::from_str(&info_json).expect("valid DocumentInfoResult"); assert_eq!(info.scanned_pages_count, 1); @@ -252,7 +253,8 @@ async fn test_mcp_server_scanned_warnings_in_tools() { page_end: 2, max_chars: Some(4000), })) - .await; + .await + .expect("tool call should succeed"); assert!( read_json.contains("--- Página 1 [📷 Imagen Escaneada / Sin Capa de Texto] ---"), From 63c58b9048a337435c380a894fb942fe24573aa5 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:32:10 +0000 Subject: [PATCH 07/11] fix(mcp): reject unknown document_id in search document_search, document_search_hybrid, document_get_context and document_get_evidence fell back to every indexed document when the document_id did not exist, so a typo in an id with accents returned hits from another book without any warning. An unknown document_id is now a tool error that lists the available ids. Without document_id the tools still search every document. --- src/mcp/server.rs | 23 ++++++----- src/mcp/tools.rs | 8 ++-- tests/mcp_tools_suite_test.rs | 78 ++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/mcp/server.rs b/src/mcp/server.rs index ee67701..0ea8ef5 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -107,16 +107,19 @@ impl DocuGraphServer { Ok(()) } - /// Helper to fetch a target document or all loaded documents. - fn get_documents(&self, doc_id: Option<&str>) -> Vec { - if let Some(doc) = doc_id.and_then(|id| self.store.get(id)) { - return vec![doc]; + /// Documents to search: the one named by `doc_id`, or every indexed document. + /// + /// An unknown `doc_id` is an error listing the available ids. It used to fall back to + /// searching every document, which hid typos behind results from the wrong book. + fn get_documents(&self, doc_id: Option<&str>) -> Result, ToolError> { + if let Some(id) = doc_id { + return self.require_document(id).map(|doc| vec![doc]); } let metas = self.store.list_documents(); - metas + Ok(metas .into_iter() .filter_map(|m| self.store.get(&m.id)) - .collect() + .collect()) } /// Fetch a document by id or content hash, or explain which ids exist. @@ -261,7 +264,7 @@ impl DocuGraphServer { )] pub async fn document_search(&self, params: Parameters) -> ToolResult { let limit = params.0.limit.unwrap_or(5); - let docs = self.get_documents(params.0.document_id.as_deref()); + let docs = self.get_documents(params.0.document_id.as_deref())?; if docs.is_empty() { return Ok("No documents available for search.".to_string()); } @@ -281,7 +284,7 @@ impl DocuGraphServer { params: Parameters, ) -> ToolResult { let limit = params.0.limit.unwrap_or(5); - let docs = self.get_documents(params.0.document_id.as_deref()); + let docs = self.get_documents(params.0.document_id.as_deref())?; if docs.is_empty() { return Ok("No documents available for search.".to_string()); } @@ -335,7 +338,7 @@ impl DocuGraphServer { params: Parameters, ) -> ToolResult { let query = ¶ms.0.query; - let docs = self.get_documents(params.0.document_id.as_deref()); + let docs = self.get_documents(params.0.document_id.as_deref())?; if docs.is_empty() { return Ok("No documents available for context expansion.".to_string()); } @@ -363,7 +366,7 @@ impl DocuGraphServer { params: Parameters, ) -> ToolResult { let query = ¶ms.0.query; - let docs = self.get_documents(params.0.document_id.as_deref()); + let docs = self.get_documents(params.0.document_id.as_deref())?; if docs.is_empty() { return Ok("No documents available for evidence collection.".to_string()); } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 6246ec9..4fb9ee0 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -31,7 +31,7 @@ pub struct DocumentOutlineParams { pub struct DocumentSearchParams { /// Search keywords or technical terms pub query: String, - /// Optional filter for a specific document ID + /// Optional filter for a specific document ID (unknown IDs return an error) pub document_id: Option, /// Maximum number of results to return (default: 5) pub limit: Option, @@ -42,7 +42,7 @@ pub struct DocumentSearchParams { pub struct DocumentSearchHybridParams { /// Conceptual or natural language query pub query: String, - /// Optional filter for a specific document ID + /// Optional filter for a specific document ID (unknown IDs return an error) pub document_id: Option, /// Maximum number of results to return (default: 5) pub limit: Option, @@ -72,7 +72,7 @@ pub struct DocumentGetSectionParams { pub struct DocumentGetContextParams { /// Target topic, concept, or section title pub query: String, - /// Optional document identifier filter + /// Optional document identifier filter (unknown IDs return an error) pub document_id: Option, /// Maximum estimated tokens in the response (default: 1500) pub max_tokens: Option, @@ -85,7 +85,7 @@ pub struct DocumentGetContextParams { pub struct DocumentGetEvidenceParams { /// Claim, assertion, or question to gather verifiable evidence for pub query: String, - /// Optional document identifier filter + /// Optional document identifier filter (unknown IDs return an error) pub document_id: Option, /// Maximum tokens budget for evidence (default: 1200) pub max_tokens: Option, diff --git a/tests/mcp_tools_suite_test.rs b/tests/mcp_tools_suite_test.rs index 072ace8..d93bc09 100644 --- a/tests/mcp_tools_suite_test.rs +++ b/tests/mcp_tools_suite_test.rs @@ -1,5 +1,5 @@ use docugraph::document::model::{Document, DocumentMetadata, Page, SectionNode}; -use docugraph::mcp::{DocuGraphServer, tools::*}; +use docugraph::mcp::{DocuGraphServer, ToolResult, tools::*}; use docugraph::storage::DocumentStore; use rmcp::handler::server::tool::IntoCallToolResult; use rmcp::handler::server::wrapper::Parameters; @@ -303,3 +303,79 @@ async fn test_mcp_tool_error_is_sent_with_is_error() { let text = wire["content"][0]["text"].as_str().expect("text content"); assert!(text.contains("'missing-doc' not found"), "{text}"); } + +#[tokio::test] +async fn test_mcp_search_tools_reject_unknown_document_id() { + let server = create_test_server(); + let typo = Some("git-gide".to_string()); + + let check = |tool: &str, result: ToolResult| { + let err = result.expect_err(tool); + assert!( + err.message().contains("'git-gide' not found"), + "{tool}: {err}" + ); + assert!( + err.message().contains("'git-guide'"), + "{tool} should list the available ids: {err}" + ); + }; + + check( + "document_search", + server + .document_search(Parameters(DocumentSearchParams { + query: "ramas locales".to_string(), + document_id: typo.clone(), + limit: None, + })) + .await, + ); + check( + "document_search_hybrid", + server + .document_search_hybrid(Parameters(DocumentSearchHybridParams { + query: "ramas locales".to_string(), + document_id: typo.clone(), + limit: None, + bm25_weight: None, + semantic_weight: None, + structural_weight: None, + })) + .await, + ); + check( + "document_get_context", + server + .document_get_context(Parameters(DocumentGetContextParams { + query: "ramas locales".to_string(), + document_id: typo.clone(), + max_tokens: None, + max_chunks: None, + })) + .await, + ); + check( + "document_get_evidence", + server + .document_get_evidence(Parameters(DocumentGetEvidenceParams { + query: "ramas locales".to_string(), + document_id: typo, + max_tokens: None, + max_items: None, + })) + .await, + ); + + // Without document_id the search still covers every indexed document. + let hits = server + .document_search(Parameters(DocumentSearchParams { + query: "ramas locales".to_string(), + document_id: None, + limit: Some(3), + })) + .await + .expect("search without document_id"); + let hits: serde_json::Value = serde_json::from_str(&hits).expect("valid JSON hits"); + assert_eq!(hits[0]["document_id"], "git-guide"); +} From b1d89e1b99554e274ee898a6e350ca890e8ca44e Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:36:03 +0000 Subject: [PATCH 08/11] fix(mcp): bound page ranges and tool limits document_read_pages walked every number from page_start to page_end, looked each one up with a linear scan and only stopped once max_chars was reached. With page_end=4294967295 and a start near the end of a real 224-page book the call did not answer within 10 s and would have held a core for about 5-6 minutes. page_start=0 or an inverted range returned an empty header instead of an error. - Validate 1 <= page_start <= page_end, clamp page_end to the last page and return a tool error when the range holds no pages. - Add Document::pages_in_range, which walks the extracted pages. Use it in read_pages and in section expansion, where a malformed outline can also claim that a section ends at u32::MAX. - Cap limit (50), max_tokens (16000), max_chunks and max_items (20), max_chars (64000) and max_bytes (1 MiB), and document the caps in the tool schemas. - Use saturating_mul for limit * 3 and max_chunks * 2, which panicked in debug builds and wrapped around in release. --- src/document/model.rs | 15 +++ src/mcp/server.rs | 112 ++++++++++++++------- src/mcp/tools.rs | 38 +++++--- src/retrieval/context.rs | 37 ++++--- src/retrieval/hybrid.rs | 2 +- tests/mcp_tools_suite_test.rs | 172 +++++++++++++++++++++++++++++++++ tests/retrieval_hybrid_test.rs | 9 ++ 7 files changed, 317 insertions(+), 68 deletions(-) diff --git a/src/document/model.rs b/src/document/model.rs index 332c297..b6b4b75 100644 --- a/src/document/model.rs +++ b/src/document/model.rs @@ -383,6 +383,21 @@ impl Document { self.pages.iter().find(|p| p.page_number == page_number) } + /// Pages numbered `start..=end`, sorted by page number and without duplicates. + /// + /// Walks the extracted pages instead of every number in the range, so a huge `end` + /// (such as `u32::MAX` from a tool call or a broken outline) costs nothing extra. + pub fn pages_in_range(&self, start: u32, end: u32) -> Vec<&Page> { + let mut pages: Vec<&Page> = self + .pages + .iter() + .filter(|p| (start..=end).contains(&p.page_number)) + .collect(); + pages.sort_by_key(|p| p.page_number); + pages.dedup_by_key(|p| p.page_number); + pages + } + /// Return all hyperlinks extracted across all pages in this document. pub fn all_links(&self) -> Vec<&DocumentLink> { self.pages.iter().flat_map(|p| &p.links).collect() diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 0ea8ef5..f1ee311 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -263,7 +263,7 @@ impl DocuGraphServer { description = "Fast BM25 keyword search across sections and pages. Returns ranked snippets with citations." )] pub async fn document_search(&self, params: Parameters) -> ToolResult { - let limit = params.0.limit.unwrap_or(5); + let limit = bounded(params.0.limit, 5, MAX_SEARCH_LIMIT); let docs = self.get_documents(params.0.document_id.as_deref())?; if docs.is_empty() { return Ok("No documents available for search.".to_string()); @@ -283,7 +283,7 @@ impl DocuGraphServer { &self, params: Parameters, ) -> ToolResult { - let limit = params.0.limit.unwrap_or(5); + let limit = bounded(params.0.limit, 5, MAX_SEARCH_LIMIT); let docs = self.get_documents(params.0.document_id.as_deref())?; if docs.is_empty() { return Ok("No documents available for search.".to_string()); @@ -313,7 +313,7 @@ impl DocuGraphServer { let section_id = ¶ms.0.section_id; let include_parent = params.0.include_parent.unwrap_or(true); let budget = ContextBudget { - max_tokens: params.0.max_tokens.unwrap_or(1500), + max_tokens: bounded(params.0.max_tokens, 1500, MAX_CONTEXT_TOKENS), max_chunks: 10, compact: true, }; @@ -344,13 +344,13 @@ impl DocuGraphServer { } let budget = ContextBudget { - max_tokens: params.0.max_tokens.unwrap_or(1500), - max_chunks: params.0.max_chunks.unwrap_or(5), + max_tokens: bounded(params.0.max_tokens, 1500, MAX_CONTEXT_TOKENS), + max_chunks: bounded(params.0.max_chunks, 5, MAX_CONTEXT_CHUNKS), compact: true, }; let retriever = HybridRetriever::build(&docs, None, None); - let hits = retriever.search(query, budget.max_chunks * 2); + let hits = retriever.search(query, budget.max_chunks.saturating_mul(2)); Ok(ContextBuilder::build_conceptual_context( query, &hits, &docs, budget, )) @@ -372,13 +372,13 @@ impl DocuGraphServer { } let budget = ContextBudget { - max_tokens: params.0.max_tokens.unwrap_or(1200), - max_chunks: params.0.max_items.unwrap_or(4), + max_tokens: bounded(params.0.max_tokens, 1200, MAX_CONTEXT_TOKENS), + max_chunks: bounded(params.0.max_items, 4, MAX_CONTEXT_CHUNKS), compact: true, }; let retriever = HybridRetriever::build(&docs, None, None); - let hits = retriever.search(query, budget.max_chunks * 2); + let hits = retriever.search(query, budget.max_chunks.saturating_mul(2)); let bundle = ContextBuilder::build_evidence(query, &hits, budget); Ok(bundle.to_markdown()) } @@ -392,42 +392,80 @@ impl DocuGraphServer { &self, params: Parameters, ) -> ToolResult { - let max_chars = params.0.max_chars.unwrap_or(8000); - let doc = self.require_document(¶ms.0.document_id)?; + let DocumentReadPagesParams { + document_id, + page_start, + page_end, + max_chars, + } = params.0; + let max_chars = bounded(max_chars, 8000, MAX_READ_CHARS); + let doc = self.require_document(&document_id)?; + + if page_start == 0 { + return Err(ToolError::new( + "page_start must be at least 1: pages are numbered from 1.", + )); + } + if page_start > page_end { + return Err(ToolError::new(format!( + "Invalid page range {page_start}-{page_end}: page_start must not be greater than page_end." + ))); + } + let total = page_count(&doc); + if page_start > total { + return Err(ToolError::new(format!( + "Page range {page_start}-{page_end} is empty: document '{}' has {total} pages.", + doc.id + ))); + } + // Clamp the end and walk only the pages that exist, so an open-ended range such as + // page_end = u32::MAX costs the same as reading up to the last page. + let last_page = page_end.min(total); + let pages = doc.pages_in_range(page_start, last_page); + if pages.is_empty() { + return Err(ToolError::new(format!( + "Page range {page_start}-{last_page} is empty: document '{}' has no extracted pages in it.", + doc.id + ))); + } let mut out = String::new(); out.push_str(&format!( "# Lectura de Páginas: {} (pp. {}-{})\n\n", - doc.metadata.title, params.0.page_start, params.0.page_end + doc.metadata.title, page_start, last_page )); + if last_page < page_end { + out.push_str(&format!( + "*(page_end {page_end} ajustado a la última página del documento: {last_page})*\n\n" + )); + } let mut chars_count = 0; - for p in params.0.page_start..=params.0.page_end { - if let Some(page) = doc.get_page(p) { - let page_header = if page.kind == PageKind::ScannedImage { - format!( - "--- Página {} [📷 Imagen Escaneada / Sin Capa de Texto] ---\n", - p - ) - } else if page.untrusted_text_detected { - format!("--- Página {} [⚠️ Untrusted Hidden Text Detected] ---\n", p) - } else if page.kind == PageKind::Empty { - format!("--- Página {} [Página en Blanco] ---\n", p) - } else { - format!("--- Página {} ---\n", p) - }; - if chars_count + page_header.len() + page.text.len() > max_chars { - let remaining = max_chars.saturating_sub(chars_count + page_header.len()); - out.push_str(&page_header); - out.push_str(&page.text.chars().take(remaining).collect::()); - out.push_str("\n\n*(Límite de caracteres alcanzado)*\n"); - break; - } + for page in pages { + let p = page.page_number; + let page_header = if page.kind == PageKind::ScannedImage { + format!( + "--- Página {} [📷 Imagen Escaneada / Sin Capa de Texto] ---\n", + p + ) + } else if page.untrusted_text_detected { + format!("--- Página {} [⚠️ Untrusted Hidden Text Detected] ---\n", p) + } else if page.kind == PageKind::Empty { + format!("--- Página {} [Página en Blanco] ---\n", p) + } else { + format!("--- Página {} ---\n", p) + }; + if chars_count + page_header.len() + page.text.len() > max_chars { + let remaining = max_chars.saturating_sub(chars_count + page_header.len()); out.push_str(&page_header); - out.push_str(&page.text); - out.push_str("\n\n"); - chars_count += page_header.len() + page.text.len(); + out.push_str(&page.text.chars().take(remaining).collect::()); + out.push_str("\n\n*(Límite de caracteres alcanzado)*\n"); + break; } + out.push_str(&page_header); + out.push_str(&page.text); + out.push_str("\n\n"); + chars_count += page_header.len() + page.text.len(); } Ok(out) } @@ -637,7 +675,7 @@ impl DocuGraphServer { ) -> ToolResult { let doc_id = ¶ms.0.document_id; let name_or_id = ¶ms.0.name_or_id; - let max_bytes = params.0.max_bytes.unwrap_or(524_288); // 512 KB default limit + let max_bytes = bounded(params.0.max_bytes, 524_288, MAX_ATTACHMENT_BYTES); let doc = self.require_document(doc_id)?; let att = doc diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 4fb9ee0..2acf0d6 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -3,6 +3,22 @@ use rmcp::schemars::{self, JsonSchema}; use serde::{Deserialize, Serialize}; +/// Largest `limit` accepted by `document_search` and `document_search_hybrid`. +pub const MAX_SEARCH_LIMIT: usize = 50; +/// Largest `max_tokens` accepted by the section, context and evidence tools. +pub const MAX_CONTEXT_TOKENS: usize = 16_000; +/// Largest `max_chunks` / `max_items` accepted by the context and evidence tools. +pub const MAX_CONTEXT_CHUNKS: usize = 20; +/// Largest `max_chars` accepted by `document_read_pages`. +pub const MAX_READ_CHARS: usize = 64_000; +/// Largest `max_bytes` accepted by `document_read_attachment` (1 MiB). +pub const MAX_ATTACHMENT_BYTES: usize = 1_048_576; + +/// Resolve an optional size argument: `default` when absent, otherwise clamped to `1..=max`. +pub fn bounded(value: Option, default: usize, max: usize) -> usize { + value.unwrap_or(default).clamp(1, max) +} + /// Parameters for `document_ping`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PingParams { @@ -33,7 +49,7 @@ pub struct DocumentSearchParams { pub query: String, /// Optional filter for a specific document ID (unknown IDs return an error) pub document_id: Option, - /// Maximum number of results to return (default: 5) + /// Maximum number of results to return (default: 5, max: 50) pub limit: Option, } @@ -44,7 +60,7 @@ pub struct DocumentSearchHybridParams { pub query: String, /// Optional filter for a specific document ID (unknown IDs return an error) pub document_id: Option, - /// Maximum number of results to return (default: 5) + /// Maximum number of results to return (default: 5, max: 50) pub limit: Option, /// Weight for BM25 keyword score (0.0 to 1.0, default: 0.5) pub bm25_weight: Option, @@ -63,7 +79,7 @@ pub struct DocumentGetSectionParams { pub section_id: String, /// Include parent section header context (default: true) pub include_parent: Option, - /// Maximum estimated tokens to return (default: 1500) + /// Maximum estimated tokens to return (default: 1500, max: 16000) pub max_tokens: Option, } @@ -74,9 +90,9 @@ pub struct DocumentGetContextParams { pub query: String, /// Optional document identifier filter (unknown IDs return an error) pub document_id: Option, - /// Maximum estimated tokens in the response (default: 1500) + /// Maximum estimated tokens in the response (default: 1500, max: 16000) pub max_tokens: Option, - /// Maximum number of chunks to include (default: 5) + /// Maximum number of chunks to include (default: 5, max: 20) pub max_chunks: Option, } @@ -87,9 +103,9 @@ pub struct DocumentGetEvidenceParams { pub query: String, /// Optional document identifier filter (unknown IDs return an error) pub document_id: Option, - /// Maximum tokens budget for evidence (default: 1200) + /// Maximum tokens budget for evidence (default: 1200, max: 16000) pub max_tokens: Option, - /// Maximum evidence snippets (default: 4) + /// Maximum evidence snippets (default: 4, max: 20) pub max_items: Option, } @@ -98,11 +114,11 @@ pub struct DocumentGetEvidenceParams { pub struct DocumentReadPagesParams { /// Document identifier pub document_id: String, - /// Start page number (1-indexed, inclusive) + /// Start page number (1-indexed, inclusive, must not exceed page_end or the last page) pub page_start: u32, - /// End page number (1-indexed, inclusive) + /// End page number (1-indexed, inclusive; values past the last page are clamped to it) pub page_end: u32, - /// Maximum characters to return to prevent context explosion (default: 8000) + /// Maximum characters to return to prevent context explosion (default: 8000, max: 64000) pub max_chars: Option, } @@ -313,7 +329,7 @@ pub struct DocumentReadAttachmentParams { pub document_id: String, /// Filename or attachment identifier to read pub name_or_id: String, - /// Maximum bytes of content to return (default: 524288 = 512KB) + /// Maximum bytes of content to return (default: 524288 = 512 KB, max: 1048576 = 1 MiB) pub max_bytes: Option, /// Force output encoding: "text" (UTF-8, default if text) or "base64" pub encoding: Option, diff --git a/src/retrieval/context.rs b/src/retrieval/context.rs index 11bc8e6..b594ec3 100644 --- a/src/retrieval/context.rs +++ b/src/retrieval/context.rs @@ -183,25 +183,24 @@ impl ContextBuilder { out.push_str("### Contenido:\n"); let mut accumulated_tokens = estimate_tokens(&out); - for p in node.page_start..=node.page_end { - if let Some(page) = doc.get_page(p) { - let page_tokens = estimate_tokens(&page.text); - if accumulated_tokens + page_tokens > budget.max_tokens { - // Budget reached: take a slice - let remaining_tokens = budget.max_tokens.saturating_sub(accumulated_tokens); - let allowed_chars = (remaining_tokens as f32 * CHARS_PER_TOKEN) as usize; - let slice: String = page.text.chars().take(allowed_chars).collect(); - out.push_str(&format!("\n--- [Página {} (Parcial)] ---\n", p)); - out.push_str(&slice); - out.push_str( - "\n\n*(Contenido podado por límite de presupuesto de contexto)*\n", - ); - break; - } else { - out.push_str(&format!("\n--- [Página {}] ---\n", p)); - out.push_str(&page.text); - accumulated_tokens += page_tokens; - } + // Outline ranges come from the PDF, so walk the pages that exist rather than every + // number up to page_end: a broken outline can claim a section ends at u32::MAX. + for page in doc.pages_in_range(node.page_start, node.page_end) { + let p = page.page_number; + let page_tokens = estimate_tokens(&page.text); + if accumulated_tokens + page_tokens > budget.max_tokens { + // Budget reached: take a slice + let remaining_tokens = budget.max_tokens.saturating_sub(accumulated_tokens); + let allowed_chars = (remaining_tokens as f32 * CHARS_PER_TOKEN) as usize; + let slice: String = page.text.chars().take(allowed_chars).collect(); + out.push_str(&format!("\n--- [Página {} (Parcial)] ---\n", p)); + out.push_str(&slice); + out.push_str("\n\n*(Contenido podado por límite de presupuesto de contexto)*\n"); + break; + } else { + out.push_str(&format!("\n--- [Página {}] ---\n", p)); + out.push_str(&page.text); + accumulated_tokens += page_tokens; } } diff --git a/src/retrieval/hybrid.rs b/src/retrieval/hybrid.rs index 11cfdc9..b146c84 100644 --- a/src/retrieval/hybrid.rs +++ b/src/retrieval/hybrid.rs @@ -83,7 +83,7 @@ impl HybridRetriever { } // 1. BM25 search candidates (take a wider candidate set for reranking) - let candidate_limit = (limit * 3).max(20).min(self.bm25.units.len()); + let candidate_limit = limit.saturating_mul(3).max(20).min(self.bm25.units.len()); let bm25_hits = self.bm25.search(query, candidate_limit); let max_bm25 = bm25_hits diff --git a/tests/mcp_tools_suite_test.rs b/tests/mcp_tools_suite_test.rs index d93bc09..7d8b18d 100644 --- a/tests/mcp_tools_suite_test.rs +++ b/tests/mcp_tools_suite_test.rs @@ -4,6 +4,7 @@ use docugraph::storage::DocumentStore; use rmcp::handler::server::tool::IntoCallToolResult; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResponse; +use std::time::{Duration, Instant}; fn create_test_server() -> DocuGraphServer { let store = DocumentStore::new(None); // Pure in-memory for testing @@ -379,3 +380,174 @@ async fn test_mcp_search_tools_reject_unknown_document_id() { let hits: serde_json::Value = serde_json::from_str(&hits).expect("valid JSON hits"); assert_eq!(hits[0]["document_id"], "git-guide"); } + +#[test] +fn test_bounded_size_arguments() { + assert_eq!(bounded(None, 5, MAX_SEARCH_LIMIT), 5); + assert_eq!(bounded(Some(0), 5, MAX_SEARCH_LIMIT), 1); + assert_eq!(bounded(Some(7), 5, MAX_SEARCH_LIMIT), 7); + assert_eq!( + bounded(Some(usize::MAX), 5, MAX_SEARCH_LIMIT), + MAX_SEARCH_LIMIT + ); +} + +#[tokio::test] +async fn test_mcp_read_pages_clamps_page_end() { + let server = create_test_server(); + let started = Instant::now(); + let pages_md = server + .document_read_pages(Parameters(DocumentReadPagesParams { + document_id: "git-guide".to_string(), + page_start: 2, + page_end: u32::MAX, + max_chars: None, + })) + .await + .expect("an open-ended range is clamped, not rejected"); + + assert!( + started.elapsed() < Duration::from_secs(1), + "page_end=u32::MAX took {:?}", + started.elapsed() + ); + assert!(pages_md.contains("(pp. 2-5)"), "{pages_md}"); + assert!(pages_md.contains("ajustado"), "{pages_md}"); + assert!(pages_md.contains("Página 3"), "{pages_md}"); +} + +#[tokio::test] +async fn test_mcp_read_pages_rejects_empty_ranges() { + let server = create_test_server(); + let read = |page_start: u32, page_end: u32| { + server.document_read_pages(Parameters(DocumentReadPagesParams { + document_id: "git-guide".to_string(), + page_start, + page_end, + max_chars: None, + })) + }; + + let err = read(0, 2).await.expect_err("page 0 does not exist"); + assert!(err.message().contains("at least 1"), "{err}"); + + let err = read(3, 2).await.expect_err("inverted range"); + assert!(err.message().contains("Invalid page range 3-2"), "{err}"); + + let err = read(6, u32::MAX) + .await + .expect_err("range that starts past the last page"); + assert!(err.message().contains("has 5 pages"), "{err}"); + + // The fixture declares 5 pages but only pages 1-3 have text. + let err = read(4, 5).await.expect_err("range without extracted pages"); + assert!(err.message().contains("is empty"), "{err}"); +} + +#[tokio::test] +async fn test_mcp_oversized_limits_are_clamped() { + let server = create_test_server(); + + let hits = server + .document_search(Parameters(DocumentSearchParams { + query: "ramas conflictos git".to_string(), + document_id: None, + limit: Some(usize::MAX), + })) + .await + .expect("huge limit is clamped"); + let hits: serde_json::Value = serde_json::from_str(&hits).expect("valid JSON hits"); + assert!(hits.as_array().unwrap().len() <= MAX_SEARCH_LIMIT); + + let hits = server + .document_search_hybrid(Parameters(DocumentSearchHybridParams { + query: "ramas conflictos git".to_string(), + document_id: None, + limit: Some(usize::MAX), + bm25_weight: None, + semantic_weight: None, + structural_weight: None, + })) + .await + .expect("huge limit is clamped"); + let hits: serde_json::Value = serde_json::from_str(&hits).expect("valid JSON hits"); + assert!(hits.as_array().unwrap().len() <= MAX_SEARCH_LIMIT); + + let context = server + .document_get_context(Parameters(DocumentGetContextParams { + query: "ramas locales".to_string(), + document_id: None, + max_tokens: Some(usize::MAX), + max_chunks: Some(usize::MAX), + })) + .await + .expect("huge budgets are clamped"); + assert!( + context.contains(&format!("~{MAX_CONTEXT_TOKENS} tokens")), + "{context}" + ); + + let evidence = server + .document_get_evidence(Parameters(DocumentGetEvidenceParams { + query: "ramas locales".to_string(), + document_id: None, + max_tokens: Some(usize::MAX), + max_items: Some(usize::MAX), + })) + .await + .expect("huge budgets are clamped"); + assert!(evidence.contains("Evidencia Recuperada"), "{evidence}"); + + let section = server + .document_get_section(Parameters(DocumentGetSectionParams { + document_id: "git-guide".to_string(), + section_id: "ramas-locales".to_string(), + include_parent: None, + max_tokens: Some(usize::MAX), + })) + .await + .expect("huge budget is clamped"); + assert!(section.contains("1.1 Ramas Locales"), "{section}"); +} + +#[tokio::test] +async fn test_mcp_get_section_with_broken_page_range_returns_quickly() { + let store = DocumentStore::new(None); + let mut doc = Document::new(DocumentMetadata { + id: "broken-outline".to_string(), + title: "Broken Outline".to_string(), + total_pages: 2, + content_hash: "broken-outline-hash".to_string(), + ..Default::default() + }); + doc.add_page(Page::new(1, "Primera página.")); + doc.add_page(Page::new(2, "Segunda página.")); + // A malformed outline can claim that a section runs until u32::MAX. + doc.sections.push(SectionNode::new( + "whole-book", + "Whole Book", + 1, + 1, + u32::MAX, + None, + )); + store.insert(doc).expect("insert must succeed"); + let server = DocuGraphServer::with_store(store); + + let started = Instant::now(); + let content = server + .document_get_section(Parameters(DocumentGetSectionParams { + document_id: "broken-outline".to_string(), + section_id: "whole-book".to_string(), + include_parent: None, + max_tokens: None, + })) + .await + .expect("section exists"); + assert!( + started.elapsed() < Duration::from_secs(1), + "section with page_end=u32::MAX took {:?}", + started.elapsed() + ); + assert!(content.contains("Segunda página."), "{content}"); +} diff --git a/tests/retrieval_hybrid_test.rs b/tests/retrieval_hybrid_test.rs index b504cbe..addb052 100644 --- a/tests/retrieval_hybrid_test.rs +++ b/tests/retrieval_hybrid_test.rs @@ -201,3 +201,12 @@ fn test_design_patterns_dynamic_adapter() { assert_eq!(p2.name, "State"); assert!(p2.intent.unwrap().contains("internal state")); } + +#[test] +fn test_hybrid_search_with_huge_limit_does_not_overflow() { + let doc = create_sample_design_pattern_doc(); + let retriever = HybridRetriever::build(std::slice::from_ref(&doc), None, None); + // limit * 3 used to overflow: a panic in debug builds, a silent wrap in release. + let hits = retriever.search("strategy algorithms", usize::MAX); + assert!(!hits.is_empty()); +} From efce8f9c12be989b61305cc1c451a0253d73a8be Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:37:24 +0000 Subject: [PATCH 09/11] fix(serve): explain an empty or misplaced cache Without DOCUGRAPH_CACHE_DIR the cache is .docugraph_cache relative to the working directory, and MCP clients start servers from the project folder. The server then saw no documents: document_list answered [] and searches said "No documents available" without saying where they had looked. - DiskCache stores its directory as an absolute path and DocumentStore exposes it. - document_list, and the search tools with an empty store, name that directory and DOCUGRAPH_CACHE_DIR. For the search tools an empty store is a tool error. - docugraph serve prints a WARNING on stderr when the configured directory is relative, cannot be opened or holds no documents. --- src/main.rs | 8 +++-- src/mcp/server.rs | 74 +++++++++++++++++++++++++++----------- src/storage/cache.rs | 11 +++++- src/storage/store.rs | 6 ++++ tests/mcp_protocol_test.rs | 61 ++++++++++++++++++++++++++++++- 5 files changed, 136 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0546ea9..6eae856 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,14 +132,18 @@ async fn main() -> anyhow::Result<()> { .expect("setting default tracing subscriber failed"); let cli = Cli::parse(); - let cache = DiskCache::new(DiskCache::default_dir()).ok(); + let cache_dir = DiskCache::default_dir(); + let cache = DiskCache::new(&cache_dir).ok(); let store = DocumentStore::new(cache); match cli.command { Commands::Serve => { info!("Starting DocuGraph MCP server on stdio transport..."); - eprintln!("DocuGraph MCP ready to accept JSON-RPC on stdin"); let server = docugraph::mcp::DocuGraphServer::with_store(store); + for warning in server.startup_warnings(&cache_dir) { + eprintln!("WARNING: {warning}"); + } + eprintln!("DocuGraph MCP ready to accept JSON-RPC on stdin"); let service = server.serve(rmcp::transport::stdio()).await?; service.waiting().await?; } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index f1ee311..b3a0919 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -115,11 +115,16 @@ impl DocuGraphServer { if let Some(id) = doc_id { return self.require_document(id).map(|doc| vec![doc]); } - let metas = self.store.list_documents(); - Ok(metas + let docs: Vec = self + .store + .list_documents() .into_iter() .filter_map(|m| self.store.get(&m.id)) - .collect()) + .collect(); + if docs.is_empty() { + return Err(ToolError::new(self.no_documents_message())); + } + Ok(docs) } /// Fetch a document by id or content hash, or explain which ids exist. @@ -139,7 +144,8 @@ impl DocuGraphServer { .collect(); if ids.is_empty() { return ToolError::new(format!( - "Document '{doc_id}' not found: no documents are indexed." + "Document '{doc_id}' not found. {}", + self.no_documents_message() )); } ids.sort(); @@ -148,6 +154,47 @@ impl DocuGraphServer { quoted_list(&ids) )) } + + /// Explain where the server looked for documents and how to point it elsewhere. + fn no_documents_message(&self) -> String { + match self.store.cache_dir() { + Some(dir) => format!( + "No indexed documents found in cache directory '{}'. Index PDFs with `docugraph index ` and set DOCUGRAPH_CACHE_DIR to that same absolute directory for both `index` and `serve`.", + dir.display() + ), + None => "No indexed documents: this server has no cache directory and only holds documents registered in memory. Set DOCUGRAPH_CACHE_DIR to the absolute directory used by `docugraph index`.".to_string(), + } + } + + /// Problems with the cache that `docugraph serve` reports on stderr before serving. + /// + /// `configured_dir` is the directory as configured (usually `DOCUGRAPH_CACHE_DIR`). A + /// relative path depends on the directory the MCP client starts the server in, which is + /// normally the project folder, and an empty cache makes every tool answer "no documents". + pub fn startup_warnings(&self, configured_dir: &Path) -> Vec { + let mut warnings = Vec::new(); + if configured_dir.is_relative() { + let resolved = std::path::absolute(configured_dir) + .unwrap_or_else(|_| configured_dir.to_path_buf()); + warnings.push(format!( + "cache directory '{}' is relative, so it depends on the directory the server is started from (now '{}'). Set DOCUGRAPH_CACHE_DIR to an absolute path.", + configured_dir.display(), + resolved.display() + )); + } + match self.store.cache_dir() { + None => warnings.push(format!( + "cache directory '{}' could not be opened; no indexed documents will be available.", + configured_dir.display() + )), + Some(dir) if self.store.list_documents().is_empty() => warnings.push(format!( + "no indexed documents in cache directory '{}'. Run `docugraph index ` with DOCUGRAPH_CACHE_DIR set to that directory.", + dir.display() + )), + Some(_) => {} + } + warnings + } } #[tool_router(router = tool_router)] @@ -172,6 +219,9 @@ impl DocuGraphServer { )] pub async fn document_list(&self) -> String { let metas = self.store.list_documents(); + if metas.is_empty() { + return self.no_documents_message(); + } let list: Vec = metas .into_iter() .map(|m| DocumentSummary { @@ -265,10 +315,6 @@ impl DocuGraphServer { pub async fn document_search(&self, params: Parameters) -> ToolResult { let limit = bounded(params.0.limit, 5, MAX_SEARCH_LIMIT); let docs = self.get_documents(params.0.document_id.as_deref())?; - if docs.is_empty() { - return Ok("No documents available for search.".to_string()); - } - let bm25 = crate::retrieval::Bm25Index::build_from_documents(&docs, None); let hits = bm25.search(¶ms.0.query, limit); Ok(serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string())) @@ -285,10 +331,6 @@ impl DocuGraphServer { ) -> ToolResult { let limit = bounded(params.0.limit, 5, MAX_SEARCH_LIMIT); let docs = self.get_documents(params.0.document_id.as_deref())?; - if docs.is_empty() { - return Ok("No documents available for search.".to_string()); - } - let weights = HybridWeights { bm25_weight: params.0.bm25_weight.unwrap_or(0.50), semantic_weight: params.0.semantic_weight.unwrap_or(0.30), @@ -339,10 +381,6 @@ impl DocuGraphServer { ) -> ToolResult { let query = ¶ms.0.query; let docs = self.get_documents(params.0.document_id.as_deref())?; - if docs.is_empty() { - return Ok("No documents available for context expansion.".to_string()); - } - let budget = ContextBudget { max_tokens: bounded(params.0.max_tokens, 1500, MAX_CONTEXT_TOKENS), max_chunks: bounded(params.0.max_chunks, 5, MAX_CONTEXT_CHUNKS), @@ -367,10 +405,6 @@ impl DocuGraphServer { ) -> ToolResult { let query = ¶ms.0.query; let docs = self.get_documents(params.0.document_id.as_deref())?; - if docs.is_empty() { - return Ok("No documents available for evidence collection.".to_string()); - } - let budget = ContextBudget { max_tokens: bounded(params.0.max_tokens, 1200, MAX_CONTEXT_TOKENS), max_chunks: bounded(params.0.max_items, 4, MAX_CONTEXT_CHUNKS), diff --git a/src/storage/cache.rs b/src/storage/cache.rs index 19a6679..321f4dc 100644 --- a/src/storage/cache.rs +++ b/src/storage/cache.rs @@ -16,8 +16,12 @@ pub struct DiskCache { impl DiskCache { /// Create a new disk cache in the specified directory. + /// + /// A relative path is resolved against the current directory and stored as absolute, so + /// messages about the cache always name the real location. pub fn new(cache_dir: impl AsRef) -> Result { - let dir = cache_dir.as_ref().to_path_buf(); + let dir = cache_dir.as_ref(); + let dir = std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()); fs::create_dir_all(&dir) .with_context(|| format!("Failed to create cache directory at {:?}", dir))?; Ok(Self { cache_dir: dir }) @@ -30,6 +34,11 @@ impl DiskCache { .unwrap_or_else(|_| PathBuf::from(".docugraph_cache")) } + /// Absolute directory holding the cached documents. + pub fn cache_dir(&self) -> &Path { + &self.cache_dir + } + /// Path to a cached document JSON file by SHA-256 hash. fn cache_path_for_hash(&self, content_hash: &str) -> PathBuf { self.cache_dir.join(format!("{}.json", content_hash)) diff --git a/src/storage/store.rs b/src/storage/store.rs index 0d0abe4..73afa7d 100644 --- a/src/storage/store.rs +++ b/src/storage/store.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, RwLock}; use tracing::debug; @@ -31,6 +32,11 @@ impl DocumentStore { store } + /// Directory of the disk cache backing this store, if it has one. + pub fn cache_dir(&self) -> Option<&Path> { + self.disk.as_ref().map(DiskCache::cache_dir) + } + /// Insert or update a document in memory and optionally persist to disk cache. pub fn insert(&self, doc: Document) -> Result<()> { if let Some(ref cache) = self.disk { diff --git a/tests/mcp_protocol_test.rs b/tests/mcp_protocol_test.rs index 54c60f8..df14d41 100644 --- a/tests/mcp_protocol_test.rs +++ b/tests/mcp_protocol_test.rs @@ -1,9 +1,11 @@ use docugraph::document::model::{Document, DocumentMetadata}; use docugraph::mcp::{ DocuGraphServer, - tools::{DocumentInfoParams, PingParams}, + tools::{DocumentInfoParams, DocumentSearchParams, PingParams}, }; +use docugraph::storage::DocumentStore; use rmcp::{ServerHandler, handler::server::wrapper::Parameters}; +use std::path::Path; use tempfile::{TempDir, tempdir}; /// Server backed by a throwaway cache dir, so tests never write `.docugraph_cache` into the repo. @@ -108,3 +110,60 @@ async fn test_document_info_tool() { assert_eq!(parsed["title"], "GoF Design Patterns"); assert!(parsed["sections_preview"].is_array()); } + +#[tokio::test] +async fn test_empty_cache_is_explained_with_its_absolute_path() { + let (server, cache) = server_with_temp_cache(); + let cache_dir = std::path::absolute(cache.path()).expect("absolute cache path"); + + let message = server.document_list().await; + assert!( + message.contains(&cache_dir.display().to_string()), + "document_list should name the cache directory: {message}" + ); + assert!(message.contains("DOCUGRAPH_CACHE_DIR"), "{message}"); + + let err = server + .document_search(Parameters(DocumentSearchParams { + query: "anything".to_string(), + document_id: None, + limit: None, + })) + .await + .expect_err("searching an empty cache is a tool error"); + assert!(err.message().contains("DOCUGRAPH_CACHE_DIR"), "{err}"); +} + +#[tokio::test] +async fn test_empty_in_memory_store_is_explained() { + let server = DocuGraphServer::with_store(DocumentStore::new(None)); + let message = server.document_list().await; + assert!(message.contains("no cache directory"), "{message}"); +} + +#[tokio::test] +async fn test_serve_startup_warnings() { + let (server, cache) = server_with_temp_cache(); + + let warnings = server.startup_warnings(cache.path()); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("no indexed documents"), "{warnings:?}"); + + // Only the configured path is inspected here; nothing is created in the working directory. + let warnings = server.startup_warnings(Path::new("relative-cache")); + assert!( + warnings.iter().any(|w| w.contains("is relative")), + "{warnings:?}" + ); + + server + .register_document(Document::new(DocumentMetadata { + id: "indexed-doc".to_string(), + title: "Indexed".to_string(), + total_pages: 1, + content_hash: "indexed-hash".to_string(), + ..Default::default() + })) + .await; + assert!(server.startup_warnings(cache.path()).is_empty()); +} From 278cca1a9627e47f1204996bfe663f6937bbe6e5 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:38:21 +0000 Subject: [PATCH 10/11] test: drive docugraph serve over stdio The MCP tests called the tool methods directly, so nothing checked the JSON-RPC framing, the isError flag that clients actually receive or that stdout stays free of logs. tests/mcp_stdio_test.rs spawns the binary (CARGO_BIN_EXE_docugraph) with a temporary cache, runs initialize and tools/list, and calls tools that fail: an unknown document, an unknown document_id in a search and a page range past the end. It checks isError, that page_end=u32::MAX answers quickly, that every stdout line is a JSON-RPC 2.0 message and that serve warns on stderr about a relative, empty cache. --- tests/mcp_stdio_test.rs | 305 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 tests/mcp_stdio_test.rs diff --git a/tests/mcp_stdio_test.rs b/tests/mcp_stdio_test.rs new file mode 100644 index 0000000..10b016a --- /dev/null +++ b/tests/mcp_stdio_test.rs @@ -0,0 +1,305 @@ +//! End-to-end tests of `docugraph serve`: spawn the real binary and speak JSON-RPC over stdio. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use docugraph::document::model::{Document, DocumentMetadata, Page}; +use docugraph::storage::DiskCache; +use serde_json::{Value, json}; +use tempfile::tempdir; + +/// Generous limit for a single response; a healthy server answers in milliseconds. +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20); + +/// A `docugraph serve` child process driven through its stdin and stdout. +struct StdioServer { + child: Child, + stdin: Option, + stdout_lines: Receiver, + stderr: Option>, + /// Every line the server wrote to stdout, in order. + transcript: Vec, + next_id: u64, +} + +impl StdioServer { + /// Start the server in `cwd` with `DOCUGRAPH_CACHE_DIR=cache_dir` and complete the handshake. + fn spawn(cwd: &Path, cache_dir: &Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_docugraph")) + .arg("serve") + .current_dir(cwd) + .env("DOCUGRAPH_CACHE_DIR", cache_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn docugraph serve"); + + let stdout = child.stdout.take().expect("child stdout"); + let (tx, stdout_lines) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + + let mut stderr_pipe = child.stderr.take().expect("child stderr"); + let stderr = thread::spawn(move || { + let mut text = String::new(); + let _ = stderr_pipe.read_to_string(&mut text); + text + }); + + let stdin = child.stdin.take(); + let mut server = Self { + child, + stdin, + stdout_lines, + stderr: Some(stderr), + transcript: Vec::new(), + next_id: 0, + }; + + let init = server.request( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "docugraph-stdio-test", "version": "0" } + }), + ); + assert_eq!( + init["result"]["serverInfo"]["name"], "docugraph-mcp", + "{init}" + ); + server.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + server + } + + fn send(&mut self, message: &Value) { + let stdin = self.stdin.as_mut().expect("stdin is open"); + writeln!(stdin, "{message}").expect("write request"); + stdin.flush().expect("flush request"); + } + + /// Send a request and wait for the response with the same id. + fn request(&mut self, method: &str, params: Value) -> Value { + self.next_id += 1; + let id = self.next_id; + self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + + let deadline = Instant::now() + RESPONSE_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let line = match self.stdout_lines.recv_timeout(remaining) { + Ok(line) => line, + Err(e) => panic!("no response to {method}: {e:?}"), + }; + let message = parse_frame(&line); + self.transcript.push(line); + if message["id"] == id { + return message; + } + } + } + + /// Call a tool and return its `isError` flag and text content. + fn call_tool(&mut self, name: &str, arguments: Value) -> (bool, String) { + let response = self.request( + "tools/call", + json!({ "name": name, "arguments": arguments }), + ); + let result = &response["result"]; + assert!( + result.is_object(), + "tools/call {name} must return a tool result, got {response}" + ); + let text: String = result["content"] + .as_array() + .expect("content array") + .iter() + .filter_map(|block| block["text"].as_str()) + .collect(); + (result["isError"] == true, text) + } + + /// Close stdin, wait for the server to exit and return what it wrote to stderr. + fn shutdown(&mut self) -> String { + drop(self.stdin.take()); + let deadline = Instant::now() + RESPONSE_TIMEOUT; + while self.child.try_wait().expect("poll child").is_none() { + assert!( + Instant::now() < deadline, + "server did not exit after stdin was closed" + ); + thread::sleep(Duration::from_millis(20)); + } + loop { + match self.stdout_lines.recv_timeout(RESPONSE_TIMEOUT) { + Ok(line) => { + parse_frame(&line); + self.transcript.push(line); + } + Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => panic!("stdout stayed open after exit"), + } + } + self.stderr + .take() + .expect("stderr reader") + .join() + .expect("stderr reader thread") + } +} + +impl Drop for StdioServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Every stdout line must be a JSON-RPC 2.0 message: logs and warnings belong on stderr. +fn parse_frame(line: &str) -> Value { + let message: Value = match serde_json::from_str(line) { + Ok(message) => message, + Err(e) => panic!("stdout line is not JSON ({e}): {line}"), + }; + assert_eq!( + message["jsonrpc"], "2.0", + "stdout line is not JSON-RPC 2.0: {line}" + ); + message +} + +/// Store a small three-page document in `cache_dir`, as `docugraph index` would. +fn write_cached_document(cache_dir: &Path) { + let mut doc = Document::new(DocumentMetadata { + id: "stdio-doc".to_string(), + title: "Stdio Test Manual".to_string(), + total_pages: 3, + content_hash: "stdio-test-hash".to_string(), + ..Default::default() + }); + doc.add_page(Page::new(1, "Chapter one introduces the stdio transport.")); + doc.add_page(Page::new(2, "Chapter two covers JSON-RPC framing.")); + doc.add_page(Page::new(3, "Chapter three lists the tool errors.")); + DiskCache::new(cache_dir) + .expect("open cache dir") + .save(&doc) + .expect("save document"); +} + +#[test] +fn stdio_tool_errors_set_is_error_and_stdout_stays_json_rpc() { + let workdir = tempdir().expect("create workdir"); + let cache = tempdir().expect("create cache dir"); + write_cached_document(cache.path()); + + let mut server = StdioServer::spawn(workdir.path(), cache.path()); + + let tools = server.request("tools/list", json!({})); + let names: Vec<&str> = tools["result"]["tools"] + .as_array() + .expect("tools array") + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + for expected in [ + "document_list", + "document_info", + "document_search", + "document_read_pages", + "document_read_attachment", + ] { + assert!( + names.contains(&expected), + "tools/list lacks {expected}: {names:?}" + ); + } + + let (is_error, text) = + server.call_tool("document_info", json!({ "document_id": "missing-doc" })); + assert!(is_error, "an unknown document must set isError: {text}"); + assert!(text.contains("'missing-doc' not found"), "{text}"); + assert!( + text.contains("'stdio-doc'"), + "the error should list the ids: {text}" + ); + + let (is_error, text) = server.call_tool( + "document_search", + json!({ "query": "json-rpc framing", "document_id": "stdio-dco" }), + ); + assert!(is_error, "an unknown document_id must set isError: {text}"); + assert!(text.contains("'stdio-doc'"), "{text}"); + + let started = Instant::now(); + let (is_error, text) = server.call_tool( + "document_read_pages", + json!({ "document_id": "stdio-doc", "page_start": 2, "page_end": u32::MAX }), + ); + assert!(!is_error, "{text}"); + assert!(text.contains("(pp. 2-3)"), "{text}"); + assert!(text.contains("JSON-RPC framing"), "{text}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "page_end=u32::MAX took {:?}", + started.elapsed() + ); + + let (is_error, text) = server.call_tool( + "document_read_pages", + json!({ "document_id": "stdio-doc", "page_start": 9, "page_end": u32::MAX }), + ); + assert!( + is_error, + "a range past the last page must set isError: {text}" + ); + assert!(text.contains("has 3 pages"), "{text}"); + + let stderr = server.shutdown(); + assert!(!server.transcript.is_empty()); + assert!( + !stderr.contains("WARNING"), + "an absolute cache with documents must not warn:\n{stderr}" + ); +} + +#[test] +fn stdio_serve_warns_about_a_relative_empty_cache() { + let workdir = tempdir().expect("create workdir"); + let mut server = StdioServer::spawn(workdir.path(), Path::new("relative-cache")); + + let (is_error, text) = server.call_tool("document_list", json!({})); + assert!(!is_error, "{text}"); + let resolved = workdir + .path() + .canonicalize() + .expect("canonical workdir") + .join("relative-cache"); + assert!( + text.contains(&resolved.display().to_string()), + "document_list should name the absolute cache dir {}: {text}", + resolved.display() + ); + assert!(text.contains("DOCUGRAPH_CACHE_DIR"), "{text}"); + + let (is_error, text) = server.call_tool("document_search", json!({ "query": "anything" })); + assert!( + is_error, + "searching an empty cache must set isError: {text}" + ); + + let stderr = server.shutdown(); + assert!(stderr.contains("WARNING"), "{stderr}"); + assert!(stderr.contains("is relative"), "{stderr}"); + assert!(stderr.contains("no indexed documents"), "{stderr}"); +} From 4c55212f94254300814dc60afdb014829585da49 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:38:21 +0000 Subject: [PATCH 11/11] docs: describe tool errors, limits and cache dir Document the isError behaviour, the size caps and the page_end clamp in the README tool section, set DOCUGRAPH_CACHE_DIR in both client configuration examples and explain why it must be absolute. Add an Unreleased entry to the CHANGELOG. --- CHANGELOG.md | 11 +++++++++++ README.md | 12 ++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d6ba5..d2f1fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to **DocuGraph MCP** will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **MCP tool errors:** unknown documents, sections, pages and attachments return `isError: true` with a message that lists what is available, instead of a successful result or a fake "Document Not Found" object. +- **Unknown `document_id` in searches:** `document_search`, `document_search_hybrid`, `document_get_context` and `document_get_evidence` return an error listing the indexed ids instead of silently searching every document. +- **Page ranges and sizes:** `document_read_pages` validates `1 <= page_start <= page_end` and clamps `page_end` to the last page, so `page_end = 4294967295` answers in milliseconds. `limit`, `max_tokens`, `max_chunks`/`max_items`, `max_chars` and `max_bytes` are capped, and `limit * 3` no longer overflows. +- **Empty or misplaced cache:** `document_list` names the absolute cache directory and `DOCUGRAPH_CACHE_DIR` when it finds no documents, and `docugraph serve` warns on stderr about a relative or empty cache directory. + +### Added +- End-to-end stdio test that runs `docugraph serve`, checks `isError` on failed tool calls and that stdout only carries JSON-RPC. + ## [0.1.0] - 2026-09-13 ### Added diff --git a/README.md b/README.md index 77bb445..cf7bf03 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ DocuGraph expone una suite de herramientas diseñada para el descubrimiento prog | `document_get_evidence` | `query`, `document_id`, `max_tokens`, `max_items` | Fragmentos compactos de evidencia con citas estrictas `[Doc: ... p. ... § ...]` para razonamiento factual. | | `document_read_pages` | `document_id`, `page_start`, `page_end`, `max_chars` | Lectura directa de rango de páginas con presupuesto de caracteres. | +**Errores y límites.** Si el documento, la sección, la página o el adjunto no existen, la herramienta responde con `isError: true` y un texto que dice qué hay disponible (por ejemplo, los `document_id` indexados). En las búsquedas, un `document_id` que no existe también es un error; sin `document_id` se busca en todos los documentos. El servidor acota los tamaños: `limit` hasta 50, `max_tokens` hasta 16000, `max_chunks` y `max_items` hasta 20, `max_chars` hasta 64000 y `max_bytes` hasta 1 MiB. En `document_read_pages`, `page_end` se ajusta a la última página, y un rango invertido o que empieza después del final es un error. + --- ## 🤖 Ejemplo de Razonamiento del Agente @@ -152,6 +154,8 @@ Evidencia: [Doc: manual-git p. 37 § ramas-locales]" DocuGraph se comunica mediante **stdio (JSON-RPC 2.0)** manteniendo `stdout` estrictamente limpio (los logs estructurados se emiten exclusivamente a `stderr`). +> **Carpeta de la caché.** `docugraph index` y `docugraph serve` leen los documentos de `DOCUGRAPH_CACHE_DIR`. Si no está definida, usan `.docugraph_cache` relativa al directorio de trabajo, y los clientes MCP suelen arrancar el servidor desde la carpeta del proyecto, así que no verá lo que se indexó en otro sitio. Usa la misma ruta absoluta para indexar y para servir. Al arrancar, `serve` avisa por `stderr` si la ruta es relativa o la caché está vacía, y `document_list` dice en qué carpeta buscó. + ### Antigravity IDE Agrega la configuración en tu archivo `mcp_config.json`: @@ -162,7 +166,8 @@ Agrega la configuración en tu archivo `mcp_config.json`: "command": "cargo", "args": ["run", "--manifest-path", "C:/proyectos/docugraph-mcp/Cargo.toml", "--release", "--quiet", "--", "serve"], "env": { - "RUST_LOG": "info" + "RUST_LOG": "info", + "DOCUGRAPH_CACHE_DIR": "C:/Users/tu-usuario/.docugraph_cache" } } } @@ -175,7 +180,10 @@ Agrega la configuración en tu archivo `mcp_config.json`: "mcpServers": { "docugraph": { "command": "C:/proyectos/docugraph-mcp/target/release/docugraph.exe", - "args": ["serve"] + "args": ["serve"], + "env": { + "DOCUGRAPH_CACHE_DIR": "C:/Users/tu-usuario/.docugraph_cache" + } } } }