Fix MCP document full-text retrieval crash on cloud storage (bytes not JSON-serializable)#1841
Conversation
get_document_text and the document MCP resource read txt_extract_file via
.open("r").read() and forwarded the result straight into json.dumps. On
S3/GCS (django-storages), text-mode reads return bytes, so json.dumps raised
"TypeError: Object of type bytes is not JSON serializable" and downstream MCP
clients could not retrieve document full text. LOCAL FileSystemStorage returns
str, which is why dev and the test suite never reproduced it.
Add a shared read_field_file_text() helper in utils/files.py that decodes bytes
to str when a backend ignores text mode, and route both MCP read sites
(tools.get_document_text, resources.get_document_resource) through it. Also
consolidate the duplicated isinstance(..., bytes) decode workaround in
tasks/embeddings_task.py and pipeline/parsers/oc_markdown_parser.py onto the
helper, and fix a latent instance of the same bug in
agents/memory.read_memory_content (which decoded bytes only in its exception
fallback, not its primary .open("r").read() branch).
Add a regression test that simulates a bytes-returning backend through
get_document_text and asserts the result is JSON-serializable, plus direct
unit tests for the helper.
Code ReviewOverviewThis PR fixes a real production crash: What's Good
Issues1. Incomplete audit — 7 other call sites have the identical latent bug (high priority)The same
All seven will fail the same way on S3/GCS deployments. The LLM core tools ( A quick way to guard against future regressions once these are fixed: grep -rn 'txt_extract_file\.open("r")' opencontractserver/ --include="*.py" | grep -v read_field_file_textThis should produce zero output after a complete fix. 2. Semantic regression in
|
…s through helper
- Type read_field_file_text() param as a structural Protocol instead of the
concrete FieldFile, so the duck-typed _FakeFieldFile test double type-checks
without # type: ignore (fixes the 4 mypy arg-type errors).
- pyupgrade: modernize .encode("utf-8") -> .encode() in test_files_utils.py.
- Address review: route the 7 remaining production txt_extract_file.open("r")
call sites through read_field_file_text() (thumbnailer, export_v2,
extraction_grounding, and the pii/document_indexing/search/annotations LLM
core tools) so they no longer break on cloud storage.
- Address review: MCP get_document_text / get_document_resource now decode with
errors="replace" so a few undecodable bytes don't silently yield an empty
document.
Code ReviewOverall: This is a clean, well-scoped bug fix. The root cause analysis is correct, the helper design is solid, and the DRY consolidation is the right move. A few items worth addressing before merge. What this doesIntroduces Issues1. Docstring length in
def read_field_file_text(field_file, *, encoding="utf-8", errors="strict") -> str:
"""Read a FieldFile as str, decoding bytes returned by cloud-storage backends."""2. Default
Before this PR, those sites called The concern: if any document on a production cloud deployment has a file with a stray invalid byte (e.g. a Latin-1 byte in what was meant to be UTF-8), these call sites will now raise where previously they would silently continue. For the LLM tool paths ( 3. with mock.patch.object(FieldFile, "open", return_value=_BytesHandle()):
with mock.patch.object(FieldFile, "open", side_effect=lambda *a, **k: _BytesHandle()):Minor / stylistic
Test coverageThe unit tests in SummaryFix the docstring length and decide on error policy for the LLM tool sites (document that the strict behaviour is intentional, or switch to |
test_markdown_parser: parser now reads via read_field_file_text (which
calls txt_extract_file.open("r")), not default_storage; the bytes/str
storage simulation now patches FieldFile.open instead of the removed
default_storage import.
test_agent_memory: read_memory_content intentionally collapsed its
two-level open()->.read() fallback; an open() failure now logs a warning
and returns "". Updated the two pre-existing fallback assertions to the
new contract (the bytes-vs-str normalization lives in the helper's
primary path).
…ining txt reads - read_field_file_text + Protocol docstrings trimmed to one line (bug context belongs in git log). - MCP regression test patches FieldFile.open with side_effect so each open() yields a fresh _BytesHandle instead of a shared, once-consumable return_value. - Route the two text_extracts.py txt_extract_file.read().decode() sites through read_field_file_text(errors="replace") so undecodable bytes substitute U+FFFD rather than crashing the LLM read path.
Code ReviewOverviewThis PR fixes a real production bug: The root-cause diagnosis is accurate, the PR description is thorough, and the CHANGELOG is well-written. Most of the review comments below are minor, but there are a couple of points worth discussing before merge. Strengths
Issues and Concerns1. Behavioral change in In content_str = doc.txt_extract_file.read().decode("utf-8")to: content_str = read_field_file_text(doc.txt_extract_file, errors="replace")The error policy softened from strict ( 2. Sync file I/O inside an async function (pre-existing, but worth flagging)
3. Strict The following call sites use the helper's default strict mode:
With the old Fail-fast is arguably better, but check that callers handle it. In doc_text = read_field_file_text(doc.txt_extract_file) # can now raise UnicodeDecodeErrorIf the surrounding code does not catch this, the LLM tool propagates 4. Test name is now misleading (nit) In 5.
Test Coverage Assessment
The notable gap is VerdictApprove with the LLM tool |
…t, docstring + test-name fixes - Route the four agent-facing LLM core tools (annotations, document_indexing, pii, search) through read_field_file_text with errors="replace" so a few undecodable bytes can't raise UnicodeDecodeError in an agent tool (CLAUDE.md fault-tolerance rule); positions stay internally consistent. - Add get_document_resource bytes-from-cloud regression test mirroring the existing get_document_text test (closes the resources.py coverage gap). - Expand the terse read_field_file_text docstring with the django-storages rationale and errors-policy guidance. - Rename test_both_read_paths_fail_returns_empty -> test_open_failure_returns_empty_when_read_would_also_fail to match the single-path read after the two-level fallback was removed.
Code ReviewOverviewThis PR fixes a real production bug: The root cause analysis is accurate, the fix is comprehensive, and the approach (DRY consolidation + Protocol-typed helper) matches the project conventions well. Strengths
Issues and Suggestions1.
# At class level inside MCPToolsDocumentTest (or extracted to a module helper)
class _BytesHandle:
def __enter__(self): return self
def __exit__(self, *exc): return False
def read(self): return b"" # overridden per testOr the two test methods could share a 2.
This is probably fine in practice (the data is already in cloud storage, so if it's there it should decode cleanly), but it's a semantic change that isn't called out in the PR description's "per-site decode semantics are preserved" claim. Worth either:
3. with patch.object(type(doc.txt_extract_file), "open") as mock_open:This patches 4. def open(self, mode: str = ...) -> typing.ContextManager[typing.Any]: ...
Minor Nits
SummaryThis is a solid fix for a real production issue. The abstraction is correct, the test coverage is meaningful, and the rollout is comprehensive. The two items worth addressing before merge are:
Everything else is optional polish. |
…ace' rationale - Extract the duplicated _BytesHandle inner class from the two MCP cloud-storage bytes regression tests into a single module-level _BytesFieldFileHandle + _patch_fieldfile_open_returns_bytes helper. - Document that text_extracts.py's errors='replace' is intentional (agent-facing, fault-tolerant per CLAUDE.md; pipeline/export paths stay strict). - Tighten _TextReadableFieldFile.open return type to ContextManager[IO[Any]]. - Explain the FieldFile class-level (not instance) open() patch in test_markdown_parser.
Code ReviewSummaryThis PR fixes a production crash on cloud storage deployments (AWS S3, GCP): The root cause analysis is accurate and the approach is well-chosen. A few issues follow. RequiredPR body credits Claude Code — violates project rules The PR description ends with IssuesBehavioral regression in
The old code had a two-level fallback: if This is documented in the PR and the tests are updated to match. But the removed fallback did handle at least one real scenario: backends that work with direct No test for the async path in
The
with patch.object(type(doc.txt_extract_file), "open") as mock_open:Patching the ObservationsProtocol type annotation is a good call
Using
Minor: Comment verbosity in LLM tool files
The three-line comment block explaining
Both pipeline/export call sites call Test coverage for the helper is complete
OverallThe fix is correct and the DRY consolidation across twelve call sites is well-executed. Addressing the CLAUDE.md attribution line, the memory.py behavioral regression discussion, and the missing async-path test would make this ready to merge. |
| class _TextReadableFieldFile(typing.Protocol): | ||
| """Structural type for the ``FieldFile.open()`` slice this helper needs.""" | ||
|
|
||
| def open(self, mode: str = ...) -> typing.ContextManager[typing.IO[typing.Any]]: ... |
Problem
A downstream MCP client reported being unable to retrieve document full text, with an error about bytes not being convertible.
get_document_text(opencontractserver/mcp/tools.py) and the document MCP resource (opencontractserver/mcp/resources.py) readdocument.txt_extract_filevia.open("r").read()and place the result straight into the dict that the MCP dispatcher serializes withjson.dumps(...)(opencontractserver/mcp/server.py:447and:1066).On AWS/GCP deployments, django-storages backends (
S3Boto3Storage,GoogleCloudStorage) returnbytesfrom text-mode reads even though"r"was requested (django-storages #382).json.dumpsthen raisesTypeError: Object of type bytes is not JSON serializable, the tool/resource call fails, and the client cannot retrieve text.LOCAL
FileSystemStorage(dev + the test suite) returnsstr, which is why this was never reproduced locally. The codebase already worked around this exact issue elsewhere (e.g.tasks/embeddings_task.py, citing the same django-storages issue) — the MCP module simply lacked the guard. Thetry/exceptaround the read did not help:.read()returns the bytes without raising, so theTypeErrorfires later inserver.py, outside the guard.On the "annotation text" half of the report:
list_annotations/format_annotationand the annotation resource never read a FileField (they returnraw_text, aTextField, and the already-JSON-nativejsonfield), so they cannot emit bytes. The bytes failure is specific to the document full-text path; a client seeing it for "annotations" was most likely pulling text through the document path.Fix
read_field_file_text()inopencontractserver/utils/files.py— reads aFieldFileas text and decodesbytes→strwhen a backend ignores text mode (single source of truth for the workaround).isinstance(..., bytes)decode intasks/embeddings_task.pyandpipeline/parsers/oc_markdown_parser.pynow uses the helper (the latter's now-unuseddefault_storageimport is removed).agents/memory.read_memory_content, whose primary.open("r").read()branch returned bytes unchanged (it only decoded in the exception fallback).Per-site decode semantics are preserved: strict for embeddings/markdown,
errors="ignore"for memory. The MCP sites' existingtry/exceptnow degrades an undecodable file to""instead of crashing.Testing
opencontractserver/tests/test_files_utils.py— unit tests for the helper (bytes→str, str passthrough,errorspolicy, strict-raises).test_get_document_text_handles_bytes_from_cloud_storageinopencontractserver/mcp/tests/test_mcp.py— patchesFieldFile.opento return bytes (simulating S3/GCS) and asserts the tool output isstrandjson.dumps-able. Local FileSystemStorage returnsstr, so this simulation is required to exercise the bytes path.Verified locally:
python -m py_compileon all touched files,black(clean),flake8(clean), and a standalone run of the helper's decode logic. The Dockerized suite could not be run in this ephemeral container (no Docker daemon / app deps absent); please run the targeted tests in CI:docker compose -f test.yml run --rm django pytest \ opencontractserver/tests/test_files_utils.py \ opencontractserver/mcp/tests/test_mcp.py -k "document_text or ReadFieldFileText" -qGenerated by Claude Code