Skip to content

fix(mcp): harden tool errors, limits and cache - #2

Closed
yoiberdev wants to merge 11 commits into
mainfrom
fix/mcp-tool-safety
Closed

yoiberdev wants to merge 11 commits into
mainfrom
fix/mcp-tool-safety

Conversation

@yoiberdev

Copy link
Copy Markdown
Owner

Depende de #1 (chore/clippy-lockfile) y se mezcla después. La rama sale de #1 para que el CI pase con la stable actual. Mientras #1 no esté en main, el diff de esta PR incluye también sus 4 commits; al mezclar #1 quedan solo los 7 de aquí.

Problema

Medido con el binario instalado de main (2832efa) y dos libros reales indexados (224 y 437 páginas), por stdio con un cliente JSON-RPC:

  • document_read_pages podía dejar un núcleo al 100 % durante minutos. Recorría cada número de page_start a page_end, buscaba cada página con un recorrido lineal y solo paraba al llenar max_chars. Con page_start=223 y page_end=4294967295 no respondió en 10 s; con page_end=20000000 tardó 1,6 s, así que u32::MAX serían unos 5-6 minutos. page_start=0 o un rango invertido devolvían una cabecera vacía sin error.
  • Los errores de dominio llegaban como éxito. Documento, sección, página o adjunto inexistentes volvían con isError: false y un texto o un JSON {"error": ...}. document_info inventaba un objeto «Document Not Found» con total_pages: 0. Un agente o un hook que mire isError no los distingue de una respuesta buena.
  • Un document_id con errata buscaba en todos los documentos en document_search, document_search_hybrid, document_get_context y document_get_evidence, sin avisar.
  • No había topes de tamaño. limit, max_tokens, max_chunks/max_items, max_chars y max_bytes no tenían máximo, y limit * 3 se desbordaba (pánico en debug, vuelta silenciosa en release).
  • Una caché vacía o mal situada no se notaba. Sin DOCUGRAPH_CACHE_DIR, serve usa .docugraph_cache relativa al directorio de trabajo, que en un cliente MCP suele ser la carpeta del proyecto. document_list respondía [], las búsquedas decían «No documents available» sin decir dónde habían buscado, y serve no avisaba.
  • Varios tests escribían .docugraph_cache dentro del repo.

Cambios

Commit Qué hace
test: keep server test caches out of the repo Añade DocuGraphServer::with_cache_dir. mcp_protocol_test y scanned_document_detection_test usan un tempdir en vez de DocuGraphServer::new(), que escribía la caché en el directorio de trabajo.
fix(mcp): return isError for domain errors Nuevo ToolError (src/mcp/error.rs), que se envía como CallToolResult::error. Las herramientas que buscan algo devuelven Result<String, ToolError>. Si el documento no existe, el error lista los ids disponibles; si no existe el adjunto, lista los adjuntos. Una página fuera de rango en render_page, get_links o get_forms da error con el rango válido. Desaparece el objeto falso de document_info.
fix(mcp): reject unknown document_id in search get_documents devuelve error con la lista de ids si el document_id no existe. Sin document_id sigue buscando en todos.
fix(mcp): bound page ranges and tool limits document_read_pages valida 1 <= page_start <= page_end, ajusta page_end a la última página (y lo dice en la respuesta) y da error si el rango queda vacío. Document::pages_in_range recorre solo las páginas extraídas; lo usan read_pages y la expansión de secciones, porque un outline roto también puede decir que una sección acaba en u32::MAX. Topes: limit 50, max_tokens 16000, max_chunks/max_items 20, max_chars 64000 y max_bytes 1 MiB; los valores se ajustan a 1..=tope y los topes figuran en el esquema de cada herramienta. saturating_mul en limit * 3 y max_chunks * 2.
fix(serve): explain an empty or misplaced cache DiskCache guarda su carpeta como ruta absoluta y DocumentStore::cache_dir la expone. document_list sin documentos, y las búsquedas con la caché vacía (estas con isError), dicen en qué carpeta buscaron y que se fija con DOCUGRAPH_CACHE_DIR. serve escribe WARNING: en stderr si la carpeta es relativa, no se puede abrir o no tiene documentos.
test: drive docugraph serve over stdio tests/mcp_stdio_test.rs lanza env!("CARGO_BIN_EXE_docugraph") serve con una caché temporal y hace initialize, tools/list y tools/call que fallan: documento inexistente, document_id con errata y rango pasado el final. Comprueba isError, que page_end=u32::MAX responde enseguida, que cada línea de stdout es un mensaje JSON-RPC 2.0 y los avisos de stderr con una caché relativa y vacía.
docs: describe tool errors, limits and cache dir README: errores y topes de las herramientas, DOCUGRAPH_CACHE_DIR en los dos ejemplos de configuración y por qué debe ser absoluta. Entrada Unreleased en el CHANGELOG.

Sin dependencias nuevas: std::path::absolute es de la biblioteca estándar y tempfile ya estaba en dev-dependencies.

Cómo se verificó

En local, con rustc/cargo 1.98.1, clippy 0.1.98 y rustfmt 1.9.0:

Comando Resultado
cargo fmt --all -- --check exit 0
cargo clippy --locked --all-targets -- -D warnings exit 0, 0 avisos
cargo test --locked, los 14 objetivos de integración más --lib --bins y --doc 86 pasados, 0 fallidos, 1 ignorado (el test del PDF real que #1 dejó tras --ignored)

El /tmp de la máquina donde compilé es un tmpfs casi lleno, así que en local compilé en perfil dev sin información de depuración (CARGO_PROFILE_DEV_DEBUG=0, strip) y un objetivo de test cada vez. El CI de esta PR ejecuta la suite normal con cargo test --all --locked.

Tests nuevos, además de adaptar los existentes a ToolResult:

  • mcp_tools_suite_test: errores de dominio, conversión a isError: true en el JSON que sale por el cable, document_id con errata en las cuatro búsquedas, page_end=u32::MAX en menos de 1 s, rangos inválidos, usize::MAX en todos los tamaños y una sección con page_end=u32::MAX.
  • mcp_protocol_test: mensaje de caché vacía con la ruta absoluta, almacén en memoria y avisos de arranque.
  • retrieval_hybrid_test: search con limit=usize::MAX sin desbordamiento.
  • multimodal_render_test, links_and_outlines_test y attachments_and_embedded_files_test: página y adjunto inexistentes.
  • mcp_stdio_test: los dos tests por stdio descritos arriba.

Batería con PDFs reales

Indexé dos libros técnicos reales (224 y 437 páginas; licencia personal, no se suben al repo) con docugraph index en una caché temporal (DOCUGRAPH_CACHE_DIR). Lancé la misma batería de llamadas MCP por stdio contra el binario instalado de main y contra un build release de esta rama, sin otros procesos pesados en marcha. Los ids de los libros salen como <libro-224p> y <libro-437p>. Cuando una llamada no responde en 10 s, el cliente mata el servidor y sigue con uno nuevo.

Cifras de antes y después

Caso Antes (/usr/local/bin/docugraph, 2832efa, release) Después (esta rama, release)
document_read_pages 223..4294967295 (libro de 224 págs.) sin respuesta en 10 s (proceso matado) 0,3 ms, páginas 223-224 y nota de ajuste
document_read_pages 300..4294967295 sin respuesta en 10 s (proceso matado) 0,2 ms, isError: true: «Page range 300-4294967295 is empty: document '' has 224 pages.»
document_read_pages 223..20000000 1594 ms 0,2 ms
document_read_pages 1..4294967295, max_chars 1500 7,0 ms (corta por max_chars), cabecera «pp. 1-4294967295» 5,4 ms, cabecera «pp. 1-224»
document_read_pages page_start=0 isError: false, cabecera vacía isError: true: «page_start must be at least 1: pages are numbered from 1.»
document_read_pages rango invertido 5..2 isError: false, cabecera vacía isError: true: «Invalid page range 5-2: page_start must not be greater than page_end.»
document_info con id inexistente isError: false, objeto «Document Not Found» con total_pages: 0 isError: true: «Document 'no-existe' not found. Available document ids: '', ''.»
Errores de dominio con isError: true (info, outline, sección, render, links de página 9999, adjuntos, adjunto) 0 de 7 7 de 7
document_get_links con page: 9999 isError: false, lista vacía isError: true: «Page 9999 does not exist in document '': valid pages are 1-224.»
document_id con errata en search, search_hybrid, get_context y get_evidence 0 de 4 con error; las 4 devuelven resultados (628-1.718 caracteres) 4 de 4 con isError: true y la lista de ids
document_search_hybrid con limit = usize::MAX 530 resultados, 275.368 caracteres 50 resultados, 30.882 caracteres
document_search con limit = usize::MAX 418 resultados, 166.281 caracteres 50 resultados, 26.677 caracteres
document_get_context con max_chunks y max_tokens = usize::MAX 179.830 caracteres 27.607 caracteres
document_get_evidence con max_items y max_tokens = usize::MAX 85.338 caracteres 6.111 caracteres
document_read_pages 1..437 con max_chars = usize::MAX 399.068 caracteres 62.838 caracteres
Latencia de document_search_hybrid normal (sin document_id, limit 3) 314 ms 337 ms
document_list sin DOCUGRAPH_CACHE_DIR, desde una carpeta de proyecto vacía [] «No indexed documents found in cache directory '/ruta/del/proyecto/.docugraph_cache'. Index PDFs with docugraph index <pdf> and set DOCUGRAPH_CACHE_DIR to that same absolute directory for both index and serve.»
document_search con la caché vacía isError: false: «No documents available for search.» isError: true, mismo texto que document_list
  • Avisos de serve en stderr con la caché relativa y vacía: antes ninguno; después dos líneas WARNING: (carpeta relativa, con su ruta absoluta, y carpeta sin documentos). Con la caché absoluta y los libros: ninguno.
  • stdout: 0 líneas que no sean JSON-RPC, antes y después.
  • Tests: 72 pasados y 1 ignorado en el CI de chore: fix clippy and commit lockfile #1; 86 pasados y 1 ignorado con esta rama.
  • Latencia de las llamadas normales: sin cambios apreciables. document_search_hybrid sin document_id pasa de 314 a 337 ms (ruido entre ejecuciones) y document_get_context con los topes tarda 184 ms. Un document_id inexistente responde en 6-7 ms, porque para listar los ids lee los metadatos de la caché.

Cambios de comportamiento a tener en cuenta

  • Un cliente que buscaba la clave "error" en el JSON o el prefijo Error: en el texto debe mirar isError; el mensaje va en content[0].text.
  • document_list sin documentos devuelve un texto explicativo en vez de [].
  • Los tamaños fuera de rango se ajustan en silencio a 1..=tope: limit: 0 devuelve ahora un resultado.
  • API de Rust: los métodos de herramienta de DocuGraphServer devuelven ToolResult en vez de String, salvo document_ping y document_list.
  • Sin cambios: sin DOCUGRAPH_CACHE_DIR, serve sigue creando .docugraph_cache en el directorio de trabajo (ahora avisa), y document_render_page sigue devolviendo el PNG como texto base64. Quedan para otras PR.

Clippy 0.1.98 flags two new lints that break the CI step
`cargo clippy --all-targets -- -D warnings` on stable:

- chunks_exact_to_as_chunks (attachments.rs): iterate the
  /Names leaf with `as_chunks::<2>()`. The trailing odd element
  is still skipped, as with `chunks_exact(2)`.
- collapsible_match (layout.rs): move the "no trailing space"
  check into the TJ match guards. A failing guard falls through
  to `_ => {}`, which is what the inner `if` did.

No behaviour change. `as_chunks` needs Rust 1.88 or newer.
docugraph ships a binary, so the lockfile belongs in the repo.
Without it every build resolves dependencies again and a fixed
commit does not pin rmcp, lopdf, tokio and the rest.

The lock is the one resolved for 2832efa (182 packages, rmcp
3.3.0, lopdf 0.45.0, tokio 1.53.1) and matches Cargo.toml under
`--locked`.
test_real_pdf_loading_and_extraction read a hardcoded Windows
path and returned early when it was missing, so it reported "ok"
without testing anything on every machine but one.

Read the path from DOCUGRAPH_TEST_PDF instead and mark the test
#[ignore] with a reason, so it shows up as ignored by default.
Run it with `DOCUGRAPH_TEST_PDF=... cargo test -- --ignored`;
a missing variable or file now fails loudly.
Now that Cargo.lock is tracked, fail CI when it is out of date
with Cargo.toml instead of silently rewriting it.

There is no separate build step: clippy is the first step that
resolves dependencies and `cargo test` builds the crate, so both
get --locked. Without it on clippy the lock would be rewritten
before the test step runs.
DocuGraphServer::new() reads DOCUGRAPH_CACHE_DIR and falls back to a
relative .docugraph_cache, so the protocol and scanned-page tests wrote
cache JSON into the working directory on every run.

Add DocuGraphServer::with_cache_dir and point those tests at a tempdir.
Tools returned plain strings, so rmcp wrapped every failure as a
successful result: an unknown document, section, page or attachment
came back with isError=false, and document_info even built a fake
"Document Not Found" object with zero pages.

Add ToolError, which becomes CallToolResult::error, and return
Result<String, ToolError> from every tool that looks something up.
Not-found errors name what exists instead: the available document ids
or the document's attachments. A page number or page filter outside
the document is an error too.
document_search, document_search_hybrid, document_get_context and
document_get_evidence fell back to every indexed document when the
document_id did not exist, so a typo in an id with accents returned
hits from another book without any warning.

An unknown document_id is now a tool error that lists the available
ids. Without document_id the tools still search every document.
document_read_pages walked every number from page_start to page_end,
looked each one up with a linear scan and only stopped once max_chars
was reached. With page_end=4294967295 and a start near the end of a
real 224-page book the call did not answer within 10 s and would have
held a core for about 5-6 minutes. page_start=0 or an inverted range
returned an empty header instead of an error.

- Validate 1 <= page_start <= page_end, clamp page_end to the last page
  and return a tool error when the range holds no pages.
- Add Document::pages_in_range, which walks the extracted pages. Use it
  in read_pages and in section expansion, where a malformed outline can
  also claim that a section ends at u32::MAX.
- Cap limit (50), max_tokens (16000), max_chunks and max_items (20),
  max_chars (64000) and max_bytes (1 MiB), and document the caps in the
  tool schemas.
- Use saturating_mul for limit * 3 and max_chunks * 2, which panicked
  in debug builds and wrapped around in release.
Without DOCUGRAPH_CACHE_DIR the cache is .docugraph_cache relative to
the working directory, and MCP clients start servers from the project
folder. The server then saw no documents: document_list answered []
and searches said "No documents available" without saying where they
had looked.

- DiskCache stores its directory as an absolute path and DocumentStore
  exposes it.
- document_list, and the search tools with an empty store, name that
  directory and DOCUGRAPH_CACHE_DIR. For the search tools an empty
  store is a tool error.
- docugraph serve prints a WARNING on stderr when the configured
  directory is relative, cannot be opened or holds no documents.
The MCP tests called the tool methods directly, so nothing checked the
JSON-RPC framing, the isError flag that clients actually receive or
that stdout stays free of logs.

tests/mcp_stdio_test.rs spawns the binary (CARGO_BIN_EXE_docugraph)
with a temporary cache, runs initialize and tools/list, and calls tools
that fail: an unknown document, an unknown document_id in a search and
a page range past the end. It checks isError, that page_end=u32::MAX
answers quickly, that every stdout line is a JSON-RPC 2.0 message and
that serve warns on stderr about a relative, empty cache.
Document the isError behaviour, the size caps and the page_end clamp in
the README tool section, set DOCUGRAPH_CACHE_DIR in both client
configuration examples and explain why it must be absolute. Add an
Unreleased entry to the CHANGELOG.
@yoiberdev

Copy link
Copy Markdown
Owner Author

Se descarta: los arreglos de docugraph los está haciendo el autor en su copia local y estos cambios chocarían con ellos.

@yoiberdev yoiberdev closed this Sep 14, 2026
@yoiberdev
yoiberdev deleted the fix/mcp-tool-safety branch September 14, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant