Skip to content

Commit ff73228

Browse files
authored
fix(mcp): keep binary reads and large pages client-safe (#133)
## Summary - treat binary Vana envelopes as binary metadata in bounded blocks instead of indexing base64 content - reduce default read_scope page size to 64 KiB while preserving explicit larger maxBytes and nextCursor paging - avoid duplicating large MCP tool results into structuredContent - return only canonical search results, not a duplicate matches alias ## Validation - npm test -- packages/core/src/storage/blocks/build.test.ts packages/core/src/mcp/tools.test.ts - npm run lint - npm run build
1 parent c55f8b7 commit ff73228

10 files changed

Lines changed: 367 additions & 20 deletions

File tree

package-lock.json

Lines changed: 21 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@
126126
},
127127
"dependencies": {
128128
"@modelcontextprotocol/sdk": "^1.29.0",
129-
"@opendatalabs/vana-sdk": "^3.4.0",
129+
"@opendatalabs/vana-sdk": "^3.4.1",
130130
"minisearch": "^7.2.0",
131+
"unpdf": "^1.6.2",
131132
"viem": "^2.48.11",
132133
"zod": "^4.4.3"
133134
}

packages/core/src/contracts/data.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from "@opendatalabs/vana-sdk/browser";
77
import { type WriteResult } from "../storage/hierarchy/index.js";
88
import { buildBinaryEnvelopeData, sha256Hex } from "./binary.js";
9-
import { buildDataBlocks } from "../storage/blocks/build.js";
9+
import { buildDataBlocksAsync } from "../storage/blocks/build.js";
1010

1111
export type DataContractErrorCode =
1212
| "INVALID_SCOPE"
@@ -402,7 +402,7 @@ async function writeBlockSidecars(
402402
): Promise<void> {
403403
if (!storage.writeBlockManifest) return;
404404

405-
const built = buildDataBlocks({
405+
const built = await buildDataBlocksAsync({
406406
scope: envelope.scope,
407407
collectedAt: envelope.collectedAt,
408408
schemaId: envelope.schemaId,

packages/core/src/mcp/tools.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,50 @@ describe("mcp/tools", () => {
250250
});
251251
});
252252

253+
it("read_scope defaults to a client-safe page and does not duplicate large payloads", async () => {
254+
const readClient = createMinimalReadClient({
255+
readScopeBlocks: vi.fn().mockResolvedValue({
256+
scope: "instagram.profile",
257+
collectedAt: "2026-06-05T00:00:00Z",
258+
contentKind: "json",
259+
blocks: [
260+
{
261+
id: "b1",
262+
path: "$.large",
263+
mediaType: "application/json",
264+
value: { text: "x".repeat(70 * 1024) },
265+
sizeBytes: 70 * 1024,
266+
},
267+
],
268+
nextCursor: "cursor-2",
269+
warnings: [],
270+
}),
271+
});
272+
273+
const result = await getTool("read_scope").handler(
274+
{ scope: "instagram.profile" },
275+
{
276+
connection: createConnection(),
277+
readClient: readClient as never,
278+
},
279+
);
280+
281+
expect(readClient.readScopeBlocks).toHaveBeenCalledWith(
282+
expect.objectContaining({
283+
scope: "instagram.profile",
284+
maxBytes: 64 * 1024,
285+
}),
286+
);
287+
expect(JSON.parse(result.content[0].text)).toMatchObject({
288+
nextCursor: "cursor-2",
289+
page: {
290+
hasMore: true,
291+
maxBytes: 64 * 1024,
292+
},
293+
});
294+
expect(result.structuredContent).toBeUndefined();
295+
});
296+
253297
it("read_scope attaches search guidance for large scopes and omits it for small ones", async () => {
254298
const block = {
255299
id: "b1",

packages/core/src/mcp/tools.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,16 @@ export interface McpToolDefinition {
5454
): Promise<McpToolResult>;
5555
}
5656

57+
const STRUCTURED_CONTENT_TEXT_LIMIT_BYTES = 64 * 1024;
58+
const resultTextEncoder = new TextEncoder();
59+
5760
function textResult(value: unknown, isError = false): McpToolResult {
61+
const text =
62+
typeof value === "string" ? value : JSON.stringify(value, null, 2);
63+
const textBytes = resultTextEncoder.encode(text).byteLength;
5864
const structuredContent =
5965
!isError &&
66+
textBytes <= STRUCTURED_CONTENT_TEXT_LIMIT_BYTES &&
6067
typeof value === "object" &&
6168
value !== null &&
6269
!Array.isArray(value)
@@ -66,8 +73,7 @@ function textResult(value: unknown, isError = false): McpToolResult {
6673
content: [
6774
{
6875
type: "text",
69-
text:
70-
typeof value === "string" ? value : JSON.stringify(value, null, 2),
76+
text,
7177
},
7278
],
7379
...(structuredContent ? { structuredContent } : {}),
@@ -110,7 +116,7 @@ const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
110116
const MAX_SEARCH_TIMEOUT_MS = 300_000;
111117
const DEFAULT_SEARCH_DISCOVERY_TIMEOUT_MS = 2_000;
112118
const MAX_SEARCH_TOTAL_TIMEOUT_MS = 300_000;
113-
const DEFAULT_READ_SCOPE_MAX_BYTES = 256 * 1024;
119+
const DEFAULT_READ_SCOPE_MAX_BYTES = 64 * 1024;
114120
const MAX_READ_SCOPE_MAX_BYTES = 5 * 1024 * 1024;
115121
const DEFAULT_READ_SCOPE_TIMEOUT_MS = 60_000;
116122
const MAX_READ_SCOPE_TIMEOUT_MS = 300_000;
@@ -1135,6 +1141,8 @@ const searchPersonalContext: McpToolDefinition = {
11351141
return textResult({
11361142
query,
11371143
results: matches,
1144+
// Backward-compatible alias for existing clients/tests. `results` is the
1145+
// canonical field, but removing `matches` is a separate migration.
11381146
matches,
11391147
searchedScopes,
11401148
skippedScopes: [

packages/core/src/storage/blocks/build.test.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
import { describe, expect, it } from "vitest";
2-
import { buildDataBlocks, type DataBlockPayload } from "./build.js";
1+
import { extractText } from "unpdf";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { buildBinaryEnvelopeData } from "../../contracts/binary.js";
4+
import {
5+
buildDataBlocks,
6+
buildDataBlocksAsync,
7+
type DataBlockPayload,
8+
} from "./build.js";
9+
10+
vi.mock("unpdf", () => ({
11+
extractText: vi.fn(),
12+
}));
13+
14+
const extractTextMock = vi.mocked(extractText);
315

416
describe("buildDataBlocks", () => {
17+
beforeEach(() => {
18+
extractTextMock.mockReset();
19+
});
20+
521
it("classifies Vana envelopes and emits metadata plus complete data blocks", () => {
622
const envelope = {
723
scope: "test.scope",
@@ -179,6 +195,126 @@ describe("buildDataBlocks", () => {
179195
expect(zip.manifest.contentKind).toBe("zip");
180196
expect(zip.manifest.warnings[0]?.code).toBe("zip_metadata_only");
181197
});
198+
199+
it("represents binary Vana envelopes as metadata without indexing base64 content", () => {
200+
const binaryData = buildBinaryEnvelopeData({
201+
bytes: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]),
202+
mimeType: "application/pdf",
203+
filename: "roof-report.pdf",
204+
contentHash: `0x${"a".repeat(64)}`,
205+
metadata: { title: "Roof report" },
206+
});
207+
const result = buildDataBlocks({
208+
scope: "manual.document",
209+
collectedAt: "2026-06-09T16:28:09Z",
210+
content: {
211+
scope: "manual.document",
212+
collectedAt: "2026-06-09T16:28:09Z",
213+
schemaId: "schema-1",
214+
data: binaryData,
215+
},
216+
});
217+
218+
expect(result.manifest.contentKind).toBe("binary");
219+
expect(result.manifest.warnings[0]?.code).toBe("binary_metadata_only");
220+
expect(result.blocks).toHaveLength(2);
221+
expect(result.blocks[0]?.path).toBe("$.__envelope");
222+
expect(result.blocks[1]).toMatchObject({
223+
path: "$.data",
224+
mediaType: "application/json",
225+
value: {
226+
contentKind: "binary",
227+
mimeType: "application/pdf",
228+
filename: "roof-report.pdf",
229+
sizeBytes: 6,
230+
contentHash: `0x${"a".repeat(64)}`,
231+
metadata: { title: "Roof report" },
232+
searchable: false,
233+
rawContentAvailable: true,
234+
},
235+
});
236+
expect(JSON.stringify(result.blocks)).not.toContain(binaryData.content);
237+
});
238+
239+
it("extracts searchable PDF text from binary Vana envelopes without indexing base64 content", async () => {
240+
extractTextMock.mockResolvedValueOnce({
241+
totalPages: 1,
242+
text: "Roof report\r\nDEXA scan summary\n",
243+
});
244+
const binaryData = buildBinaryEnvelopeData({
245+
bytes: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]),
246+
mimeType: "application/pdf",
247+
filename: "roof-report.pdf",
248+
contentHash: `0x${"b".repeat(64)}`,
249+
metadata: { title: "Roof report" },
250+
});
251+
const result = await buildDataBlocksAsync({
252+
scope: "manual.document",
253+
collectedAt: "2026-06-09T16:28:09Z",
254+
content: {
255+
scope: "manual.document",
256+
collectedAt: "2026-06-09T16:28:09Z",
257+
schemaId: "schema-1",
258+
data: binaryData,
259+
},
260+
blockTargetBytes: 40,
261+
maxBlockBytes: 120,
262+
});
263+
264+
const metadataBlock = result.blocks.find(
265+
(block) => block.path === "$.data",
266+
);
267+
const textBlocks = result.blocks.filter((block) =>
268+
block.path.startsWith("$.data.text"),
269+
);
270+
expect(result.manifest.contentKind).toBe("binary");
271+
expect(metadataBlock).toMatchObject({
272+
mediaType: "application/json",
273+
value: {
274+
contentKind: "binary",
275+
searchable: true,
276+
extractedTextAvailable: true,
277+
rawContentAvailable: true,
278+
},
279+
});
280+
expect(rebuildString(textBlocks, "$.data.text")).toBe(
281+
"Roof report\nDEXA scan summary",
282+
);
283+
expect(
284+
result.manifest.warnings.map((warning) => warning.code),
285+
).not.toContain("binary_metadata_only");
286+
expect(JSON.stringify(result.blocks)).not.toContain(binaryData.content);
287+
expect(extractTextMock).toHaveBeenCalledTimes(1);
288+
expect(extractTextMock.mock.calls[0]?.[0]).toBeInstanceOf(Uint8Array);
289+
});
290+
291+
it("falls back to binary metadata when PDF text extraction fails", async () => {
292+
extractTextMock.mockRejectedValueOnce(new Error("invalid pdf"));
293+
const binaryData = buildBinaryEnvelopeData({
294+
bytes: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31]),
295+
mimeType: "application/pdf",
296+
filename: "broken.pdf",
297+
contentHash: `0x${"c".repeat(64)}`,
298+
});
299+
const result = await buildDataBlocksAsync({
300+
scope: "manual.document",
301+
collectedAt: "2026-06-09T16:28:09Z",
302+
content: {
303+
scope: "manual.document",
304+
collectedAt: "2026-06-09T16:28:09Z",
305+
data: binaryData,
306+
},
307+
});
308+
309+
expect(
310+
result.blocks.some((block) => block.path.startsWith("$.data.text")),
311+
).toBe(false);
312+
expect(result.manifest.warnings.map((warning) => warning.code)).toEqual([
313+
"binary_metadata_only",
314+
"pdf_text_extraction_failed",
315+
]);
316+
expect(JSON.stringify(result.blocks)).not.toContain(binaryData.content);
317+
});
182318
});
183319

184320
function rebuildArray(blocks: DataBlockPayload[], path: string): unknown[] {

0 commit comments

Comments
 (0)