Skip to content

Commit 9b28ffa

Browse files
JSv4claude
andauthored
Fix MCP document full-text retrieval crash on cloud storage (bytes not JSON-serializable) (#1841)
* Fix MCP document full-text retrieval crash on cloud storage backends 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. * Fix linter (mypy/pyupgrade) and route remaining txt_extract_file reads 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. * Fix stale test expectations after MCP cloud-storage read refactor 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). * Address review: trim helper docstrings, fresh open handle, route remaining 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. * Address review: errors=replace on LLM tool reads, resources bytes test, 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. * Address review: dedup _BytesHandle test helper, document errors='replace' 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26a864b commit 9b28ffa

19 files changed

Lines changed: 317 additions & 77 deletions

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9898
bug pre-dated the Phase 6 service-layer refactor (it was not a regression).
9999
Regression test added: `test_smart_label_list_denies_unreadable_corpus`.
100100

101+
### Fixed
102+
103+
- **MCP document full-text retrieval crashed on cloud storage**
104+
(`opencontractserver/mcp/tools.py::get_document_text`,
105+
`opencontractserver/mcp/resources.py::get_document_resource`) — both read
106+
`document.txt_extract_file` via `.open("r").read()` and placed the raw result
107+
into the dict that the MCP dispatcher serializes with `json.dumps(...)`
108+
(`opencontractserver/mcp/server.py:447` and `:1066`). On AWS/GCP deployments
109+
django-storages backends (`S3Boto3Storage`, `GoogleCloudStorage`) return
110+
`bytes` from text-mode reads (django-storages #382), so `json.dumps` raised
111+
`TypeError: Object of type bytes is not JSON serializable` and downstream MCP
112+
clients could not retrieve document full text. LOCAL `FileSystemStorage`
113+
returns `str`, which is why dev and the test suite never reproduced it. Both
114+
call sites now decode through the new `read_field_file_text()` helper
115+
(`opencontractserver/utils/files.py`), which centralizes the
116+
`isinstance(..., bytes)` decode workaround previously duplicated in
117+
`tasks/embeddings_task.py` and `pipeline/parsers/oc_markdown_parser.py`. The
118+
same routing also fixes a latent instance of this bug in the agent memory
119+
reader's happy path
120+
(`opencontractserver/agents/memory.py::read_memory_content`, which decoded
121+
bytes only in its exception fallback, not its primary `.open("r").read()`
122+
branch). Regression test:
123+
`test_get_document_text_handles_bytes_from_cloud_storage`
124+
(`opencontractserver/mcp/tests/test_mcp.py`); helper unit tests:
125+
`opencontractserver/tests/test_files_utils.py`.
126+
- **Routed the remaining seven production `txt_extract_file.open("r")` call
127+
sites through `read_field_file_text()`** so they no longer break on cloud
128+
storage the same way: `pipeline/base/thumbnailer.py`, `utils/export_v2.py`,
129+
`utils/extraction_grounding.py`, and the four LLM core tools
130+
(`llms/tools/core_tools/{pii,document_indexing,search,annotations}.py`). The
131+
LLM tools are the highest-impact because they feed `doc_text` directly into
132+
agent context, where a raw `bytes` value would crash the downstream call or
133+
produce garbage. The two MCP read sites and the four LLM core tools now pass
134+
`errors="replace"` so a few undecodable bytes substitute `U+FFFD` instead of
135+
raising `UnicodeDecodeError`: at the MCP sites it would otherwise be caught
136+
by the surrounding `except` and silently return an empty document; in the
137+
agent tools it keeps the tool fault-tolerant (per the CLAUDE.md agent
138+
tool-fault-tolerance rule) and stays internally consistent because match /
139+
annotation positions are computed against the same decoded string. The
140+
pipeline/export sites (`thumbnailer`, `export_v2`, `extraction_grounding`)
141+
retain strict decoding to fail fast on genuinely corrupt files. Regression
142+
test for the resource path:
143+
`test_get_document_resource_handles_bytes_from_cloud_storage`
144+
(`opencontractserver/mcp/tests/test_mcp.py`). `read_field_file_text()`'s
145+
parameter is typed as a structural `Protocol` rather than the concrete
146+
`FieldFile`, so duck-typed test doubles type-check without `# type: ignore`.
147+
101148
### Changed
102149

103150
- **`BaseService.filter_visible_qs` now fails closed** (`opencontractserver/shared/services/base.py`)

opencontractserver/agents/memory.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030
from opencontractserver.constants.document_processing import MARKDOWN_MIME_TYPE
3131
from opencontractserver.llms.context_guardrails import estimate_token_count
32+
from opencontractserver.utils.files import read_field_file_text
3233

3334
if TYPE_CHECKING:
3435
from opencontractserver.corpuses.models import Corpus
@@ -172,20 +173,13 @@ def _read() -> str:
172173
if doc is None or not doc.txt_extract_file:
173174
return ""
174175
try:
175-
with doc.txt_extract_file.open("r") as f:
176-
return f.read()
176+
return read_field_file_text(doc.txt_extract_file, errors="ignore")
177177
except Exception:
178-
try:
179-
content = doc.txt_extract_file.read()
180-
if isinstance(content, bytes):
181-
return content.decode("utf-8", errors="ignore")
182-
return content
183-
except Exception:
184-
logger.warning(
185-
"Failed to read memory document for corpus %s",
186-
corpus.id,
187-
)
188-
return ""
178+
logger.warning(
179+
"Failed to read memory document for corpus %s",
180+
corpus.id,
181+
)
182+
return ""
189183

190184
return await database_sync_to_async(_read)()
191185

opencontractserver/llms/tools/core_tools/annotations.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing_extensions import TypedDict
77

88
from opencontractserver.utils.compact_pawls import expand_pawls_pages
9+
from opencontractserver.utils.files import read_field_file_text
910

1011
from ._helpers import _db_sync_to_async
1112

@@ -279,8 +280,11 @@ def _create_annotation(pos: int, end_idx: int, label_obj):
279280
raise ValueError(
280281
f"Text document id={doc_id} lacks txt_extract_file; cannot annotate."
281282
)
282-
with doc.txt_extract_file.open("r") as f:
283-
doc_text = f.read()
283+
# errors="replace" keeps this agent tool fault-tolerant: a few
284+
# undecodable bytes substitute U+FFFD rather than raising
285+
# UnicodeDecodeError. Positions are computed against this same string,
286+
# so the substitution stays internally consistent.
287+
doc_text = read_field_file_text(doc.txt_extract_file, errors="replace")
284288

285289
label_type_const = SPAN_LABEL
286290

opencontractserver/llms/tools/core_tools/document_indexing.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing_extensions import NotRequired, TypedDict
77

88
from opencontractserver.utils.compact_pawls import expand_pawls_pages
9+
from opencontractserver.utils.files import read_field_file_text
910

1011
from ._helpers import _db_sync_to_async
1112

@@ -151,8 +152,11 @@ def _make_annotation(pos, end_idx, label_obj, title, description):
151152
f"Text document id={document_id} lacks txt_extract_file; "
152153
"cannot create index."
153154
)
154-
with doc.txt_extract_file.open("r") as f:
155-
doc_text = f.read()
155+
# errors="replace" keeps this agent tool fault-tolerant: a few
156+
# undecodable bytes substitute U+FFFD rather than raising
157+
# UnicodeDecodeError. Positions are computed against this same string,
158+
# so the substitution stays internally consistent.
159+
doc_text = read_field_file_text(doc.txt_extract_file, errors="replace")
156160

157161
label_type_const = SPAN_LABEL
158162

opencontractserver/llms/tools/core_tools/pii.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from opencontractserver.tasks.embeddings_task import (
3232
calculate_embedding_for_annotation_text,
3333
)
34+
from opencontractserver.utils.files import read_field_file_text
3435

3536
from ._helpers import _db_sync_to_async
3637
from ._privacy_filter_client import Detection, adetect_pii
@@ -107,8 +108,10 @@ def _load_doc_text_sync(document_id: int, corpus_id: int) -> _DocTextResult:
107108
if file_type in TEXT_MIMETYPES:
108109
if not doc.txt_extract_file:
109110
raise ValueError(f"Text document id={document_id} lacks txt_extract_file.")
110-
with doc.txt_extract_file.open("r") as f:
111-
doc_text = f.read()
111+
# errors="replace" keeps this agent tool fault-tolerant: a few
112+
# undecodable bytes substitute U+FFFD rather than raising
113+
# UnicodeDecodeError on read.
114+
doc_text = read_field_file_text(doc.txt_extract_file, errors="replace")
112115
return _DocTextResult(doc, corpus, doc_text, file_type, None)
113116

114117
if file_type == "application/pdf":

opencontractserver/llms/tools/core_tools/search.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from opencontractserver.documents.models import Document
77
from opencontractserver.utils.compact_pawls import expand_pawls_pages
8+
from opencontractserver.utils.files import read_field_file_text
89

910
from ._helpers import _db_sync_to_async
1011

@@ -137,8 +138,11 @@ def search_exact_text_as_sources(
137138
f"Text document id={document_id} lacks txt_extract_file; cannot search."
138139
)
139140

140-
with doc.txt_extract_file.open("r") as f:
141-
doc_text = f.read()
141+
# errors="replace" keeps this agent tool fault-tolerant: a few
142+
# undecodable bytes substitute U+FFFD rather than raising
143+
# UnicodeDecodeError. Match positions are computed against this same
144+
# string, so the substitution stays internally consistent.
145+
doc_text = read_field_file_text(doc.txt_extract_file, errors="replace")
142146

143147
# Find all matches for each search string
144148
for search_str in search_strings:

opencontractserver/llms/tools/core_tools/text_extracts.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
from datetime import datetime # noqa: F401 (used in type comment below)
55

6+
from opencontractserver.utils.files import read_field_file_text
7+
68
logger = logging.getLogger(__name__)
79

810
# --------------------------------------------------------------------------- #
@@ -101,9 +103,12 @@ def load_document_txt_extract(
101103
use_cache = cached_ts == doc.modified
102104

103105
if not use_cache:
104-
# (Re)load from storage.
105-
content_bytes = doc.txt_extract_file.read()
106-
content_str = content_bytes.decode("utf-8")
106+
# Cache miss — reload from storage; helper normalizes bytes-returning
107+
# backends. ``errors="replace"`` is intentional: this is an
108+
# agent-facing read, so per the CLAUDE.md fault-tolerance convention we
109+
# substitute U+FFFD for undecodable bytes rather than crash the tool
110+
# call (pipeline/export paths use strict mode instead).
111+
content_str = read_field_file_text(doc.txt_extract_file, errors="replace")
107112
_DOC_TXT_CACHE[document_id] = (doc.modified, content_str)
108113

109114
logger.debug(
@@ -161,7 +166,9 @@ async def aload_document_txt_extract(
161166
use_cache = cached_ts == doc.modified
162167

163168
if not use_cache:
164-
content_str = doc.txt_extract_file.read().decode("utf-8")
169+
# Agent-facing read: ``errors="replace"`` is intentional (fault-tolerant
170+
# per CLAUDE.md) — see the sibling loader above for the full rationale.
171+
content_str = read_field_file_text(doc.txt_extract_file, errors="replace")
165172
_DOC_TXT_CACHE[document_id] = (doc.modified, content_str)
166173

167174
logger.debug(

opencontractserver/mcp/resources.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from django.contrib.auth.models import AnonymousUser
1212

13+
from opencontractserver.utils.files import read_field_file_text
14+
1315
if TYPE_CHECKING:
1416
from opencontractserver.users.types import UserOrAnonymous
1517

@@ -87,8 +89,12 @@ def get_document_resource(
8789
full_text = ""
8890
if document.txt_extract_file:
8991
try:
90-
with document.txt_extract_file.open("r") as f:
91-
full_text = f.read()
92+
# errors="replace" so a few undecodable bytes substitute U+FFFD
93+
# rather than raising UnicodeDecodeError and silently yielding an
94+
# empty document to the client.
95+
full_text = read_field_file_text(
96+
document.txt_extract_file, errors="replace"
97+
)
9298
except Exception:
9399
full_text = ""
94100

opencontractserver/mcp/tests/test_mcp.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import json
5+
from unittest import mock
56

67
import pytest
78
from django.contrib.auth import get_user_model
@@ -13,6 +14,42 @@
1314
User = get_user_model()
1415

1516

17+
class _BytesFieldFileHandle:
18+
"""Context-manager file handle whose ``read()`` returns ``bytes``.
19+
20+
Simulates cloud storage backends (S3Boto3Storage / GoogleCloudStorage via
21+
django-storages #382) which return ``bytes`` from ``FieldFile.open("r")``
22+
even in text mode — the path that ``read_field_file_text`` normalizes and
23+
that local ``FileSystemStorage`` never exercises.
24+
"""
25+
26+
def __init__(self, payload: str):
27+
self._payload = payload.encode("utf-8")
28+
29+
def __enter__(self):
30+
return self
31+
32+
def __exit__(self, *exc):
33+
return False
34+
35+
def read(self):
36+
return self._payload
37+
38+
39+
def _patch_fieldfile_open_returns_bytes(payload: str):
40+
"""Patch ``FieldFile.open`` so each call yields a fresh bytes-returning handle.
41+
42+
Patches the *class* (not an instance) because ``FieldFile.open`` is the
43+
only reliable interception point; a fresh handle per call keeps each
44+
``open()`` reader unconsumed.
45+
"""
46+
from django.db.models.fields.files import FieldFile
47+
48+
return mock.patch.object(
49+
FieldFile, "open", side_effect=lambda *a, **k: _BytesFieldFileHandle(payload)
50+
)
51+
52+
1653
class _MCPAsyncRunMixin:
1754
"""Shared helper for running an async coroutine inside a sync test method.
1855
@@ -616,6 +653,51 @@ def test_get_document_text_nonexistent(self):
616653
with self.assertRaises(Document.DoesNotExist):
617654
get_document_text(self.corpus.slug, "nonexistent-doc")
618655

656+
def test_get_document_text_handles_bytes_from_cloud_storage(self):
657+
"""Regression: cloud backends (S3/GCS) return bytes from ``.open("r")``
658+
even in text mode (django-storages #382).
659+
660+
The tool must normalize to ``str`` so the dispatcher's
661+
``json.dumps(result)`` does not raise ``TypeError: Object of type
662+
bytes is not JSON serializable`` — the failure reported by downstream
663+
MCP clients on cloud-storage deployments. Local FileSystemStorage
664+
returns ``str``, so without this simulation the suite never exercises
665+
the bytes path.
666+
"""
667+
from opencontractserver.mcp.tools import get_document_text
668+
669+
payload = "Bytes-backed extracted text ✓"
670+
671+
with _patch_fieldfile_open_returns_bytes(payload):
672+
result = get_document_text(self.corpus.slug, self.doc1.slug)
673+
674+
# Decoded to str ...
675+
self.assertIsInstance(result["text"], str)
676+
self.assertEqual(result["text"], payload)
677+
# ... and the full payload serializes cleanly (the original crash).
678+
json.dumps(result, indent=2)
679+
680+
def test_get_document_resource_handles_bytes_from_cloud_storage(self):
681+
"""Regression: the resource path shares the same bytes-from-cloud bug
682+
class as ``get_document_text`` (django-storages #382).
683+
684+
``get_document_resource`` itself returns a JSON string, so the latent
685+
failure is the same ``read_field_file_text`` decode rather than the
686+
dispatcher serialization. This pins the resource path independently so
687+
the two surfaces can't drift.
688+
"""
689+
from opencontractserver.mcp.resources import get_document_resource
690+
691+
payload = "Resource bytes-backed extracted text ✓"
692+
693+
with _patch_fieldfile_open_returns_bytes(payload):
694+
result = get_document_resource(self.corpus.slug, self.doc1.slug)
695+
696+
data = json.loads(result)
697+
self.assertIsInstance(data["full_text"], str)
698+
self.assertEqual(data["full_text"], payload)
699+
self.assertEqual(data["text_preview"], payload[:500])
700+
619701

620702
class MCPToolsAnnotationsTest(TestCase):
621703
"""Tests for MCP annotation-related tools."""

opencontractserver/mcp/tools.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from django.db.models import Count, Q
1515

1616
from opencontractserver.constants.mcp import MAX_THREAD_MESSAGE_LENGTH
17+
from opencontractserver.utils.files import read_field_file_text
1718

1819
from .formatters import (
1920
format_annotation,
@@ -148,8 +149,12 @@ def get_document_text(
148149
full_text = ""
149150
if document.txt_extract_file:
150151
try:
151-
with document.txt_extract_file.open("r") as f:
152-
full_text = f.read()
152+
# errors="replace" so a few undecodable bytes substitute U+FFFD
153+
# rather than raising UnicodeDecodeError and silently yielding an
154+
# empty document to the client.
155+
full_text = read_field_file_text(
156+
document.txt_extract_file, errors="replace"
157+
)
153158
except Exception:
154159
full_text = ""
155160

0 commit comments

Comments
 (0)