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 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/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/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/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" 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" + } } } } 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(' '); } _ => {} } 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/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/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 8796871..b3a0919 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -6,14 +6,19 @@ use rmcp::{ model::{ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router, }; +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 { @@ -44,10 +49,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), @@ -95,16 +107,93 @@ 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 + 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. + 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. {}", + self.no_documents_message() + )); + } + ids.sort(); + ToolError::new(format!( + "Document '{doc_id}' not found. Available document ids: {}.", + 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 } } @@ -130,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 { @@ -152,75 +244,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(); - - 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 - }; + 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 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. @@ -228,20 +295,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. @@ -249,16 +312,12 @@ 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 { - 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(); - } - + 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())?; 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. @@ -269,13 +328,9 @@ impl DocuGraphServer { pub async fn document_search_hybrid( &self, params: Parameters, - ) -> String { - 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(); - } - + ) -> ToolResult { + let limit = bounded(params.0.limit, 5, MAX_SEARCH_LIMIT); + let docs = self.get_documents(params.0.document_id.as_deref())?; let weights = HybridWeights { bm25_weight: params.0.bm25_weight.unwrap_or(0.50), semantic_weight: params.0.semantic_weight.unwrap_or(0.30), @@ -284,7 +339,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. @@ -295,24 +350,24 @@ 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); 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, }; - 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. @@ -323,22 +378,20 @@ 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(); - } - + let docs = self.get_documents(params.0.document_id.as_deref())?; 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); - ContextBuilder::build_conceptual_context(query, &hits, &docs, budget) + let hits = retriever.search(query, budget.max_chunks.saturating_mul(2)); + Ok(ContextBuilder::build_conceptual_context( + query, &hits, &docs, budget, + )) } /// Retrieve compact evidence snippets with guaranteed citation provenance for LLM reasoning. @@ -349,23 +402,19 @@ 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(); - } - + let docs = self.get_documents(params.0.document_id.as_deref())?; 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); - bundle.to_markdown() + Ok(bundle.to_markdown()) } /// Read raw text from a specific page range with a character budget. @@ -373,49 +422,86 @@ 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; - let max_chars = params.0.max_chars.unwrap_or(8000); + pub async fn document_read_pages( + &self, + params: Parameters, + ) -> ToolResult { + 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 + ))); + } - if let Some(doc) = self.store.get(doc_id) { - let mut out = String::new(); + let mut out = String::new(); + out.push_str(&format!( + "# Lectura de Páginas: {} (pp. {}-{})\n\n", + doc.metadata.title, page_start, last_page + )); + if last_page < page_end { out.push_str(&format!( - "# Lectura de Páginas: {} (pp. {}-{})\n\n", - doc.metadata.title, params.0.page_start, params.0.page_end + "*(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; - } - out.push_str(&page_header); - out.push_str(&page.text); - out.push_str("\n\n"); - chars_count += page_header.len() + page.text.len(); - } + let mut chars_count = 0; + 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.chars().take(remaining).collect::()); + out.push_str("\n\n*(Límite de caracteres alcanzado)*\n"); + break; } - out - } else { - format!("Error: Document '{doc_id}' not found.") + 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) } /// Render a specific document page to a high-resolution PNG image for visual multimodal inspection. @@ -423,42 +509,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. @@ -466,66 +544,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() - }; + 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, - }); - } + 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. @@ -533,60 +609,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(); - - for field in &doc.forms { - if page_filter.is_some_and(|target_p| field.page_number != Some(target_p)) { - continue; - } + let doc = self.require_document(doc_id)?; + if let Some(p) = page_filter { + check_page(&doc, p)?; + } - if filled_only - && field - .value - .as_deref() - .map(|v| v.trim().is_empty()) - .unwrap_or(true) - { - continue; - } + let mut results = Vec::new(); - 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. @@ -597,40 +671,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). @@ -641,71 +706,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 max_bytes = bounded(params.0.max_bytes, 524_288, MAX_ATTACHMENT_BYTES); - 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 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 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/src/mcp/tools.rs b/src/mcp/tools.rs index 6246ec9..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 { @@ -31,9 +47,9 @@ 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) + /// Maximum number of results to return (default: 5, max: 50) pub limit: Option, } @@ -42,9 +58,9 @@ 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) + /// 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, } @@ -72,11 +88,11 @@ 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) + /// 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, } @@ -85,11 +101,11 @@ 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) + /// 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/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/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 60fc2a6..df14d41 100644 --- a/tests/mcp_protocol_test.rs +++ b/tests/mcp_protocol_test.rs @@ -1,13 +1,22 @@ 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. +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 +26,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 +45,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 +79,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(), @@ -93,10 +102,68 @@ 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"); 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()); +} 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}"); +} diff --git a/tests/mcp_tools_suite_test.rs b/tests/mcp_tools_suite_test.rs index c29845c..7d8b18d 100644 --- a/tests/mcp_tools_suite_test.rs +++ b/tests/mcp_tools_suite_test.rs @@ -1,7 +1,10 @@ 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; +use rmcp::model::CallToolResponse; +use std::time::{Duration, Instant}; fn create_test_server() -> DocuGraphServer { let store = DocumentStore::new(None); // Pure in-memory for testing @@ -69,7 +72,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 +91,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 +112,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 +131,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 +149,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 +166,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 +184,370 @@ 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}"); +} + +#[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"); +} + +#[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/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/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!( 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()); +} diff --git a/tests/scanned_document_detection_test.rs b/tests/scanned_document_detection_test.rs index 22d194d..0456a74 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(), @@ -236,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); @@ -251,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] ---"),