@littlebigbrain/mcp 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,13 @@ missing.
69
69
 
70
70
  Read tools return compact structured envelopes by default — use `detail`, `row_limit`, and returned cursors to page without silently truncating. Write tools derive an idempotency key unless you provide one.
71
71
 
72
+ Query pages preserve complete RDF values and may contain fewer than `row_limit`
73
+ rows to fit the 80 KB UTF-8 output budget. Follow the returned `next` arguments
74
+ until absent; the cursor advances by rows actually delivered. A single row that
75
+ exceeds the budget fails explicitly: project fewer fields or use the direct
76
+ SPARQL HTTP endpoint for that row.
77
+
78
+
72
79
  Both `lbb_query` SPARQL modes (`sparql` and `structured`) support retained commit reads
73
80
  through `as_of_commit_seq`. When omitted, the connector pins the current head
74
81
  commit and reuses it for cursor pages. Valid-time `as_of` is unsupported and is
@@ -312,104 +312,70 @@ export function envelope(label, value, detailArg, next) {
312
312
  };
313
313
  }
314
314
  export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
315
- const detail = normalizeDetail(detailArg);
316
- const limits = compactLimits(detail);
317
- const state = { truncated: false };
318
- const returned = rowPage?.returned;
319
- const rowCap = rowPage
320
- ? Math.max(limits.maxItems, rowPage.returned)
321
- : limits.maxItems;
322
- let data = truncateValue(value, { ...limits, maxItems: rowCap }, state);
323
- const partialRows = rowPage
324
- ? rowPage.returned < rowPage.total || rowPage.has_more
325
- : false;
326
- const rowText = rowPage
327
- ? rowPage.returned < rowPage.total
328
- ? `returned ${rowPage.returned} of ${rowPage.total} rows`
329
- : `returned ${rowPage.returned} rows`
330
- : undefined;
331
- const serverFlags = serverTruncationFlags(value);
332
- const serverTruncated = serverFlags.length > 0;
333
- const serverText = serverTruncated
334
- ? ` [server-truncated: ${serverFlags.join(", ")}]`
335
- : "";
336
- let result = {
337
- summary: rowText
338
- ? `${label}: ${rowText}${serverText}${state.truncated ? " [truncated output]" : ""}`
339
- : defaultSummary(label, value, state.truncated),
340
- data,
341
- counts: countsFor(value),
342
- row_page: rowPage,
343
- truncated: state.truncated || partialRows || serverTruncated || undefined,
344
- next: partialRows && next
345
- ? next
346
- : state.truncated && nextDetail(detail)
347
- ? { detail: nextDetail(detail) }
348
- : next,
349
- };
350
- let text = JSON.stringify(result, null, 2);
351
- if (text.length <= HARD_OUTPUT_CHARS)
352
- return result;
353
- // The full result overflows one MCP tool result, so the displayed rows are
354
- // capped to fit. `row_page`/`counts` still describe the *server* page, so
355
- // reporting only those reads as "every row delivered" even when the display
356
- // was cut — the recurring MCP false-positive. So: state shown-vs-returned
357
- // explicitly (`rows_shown`), and give advice that matches reality —
358
- // * server itself withheld rows (partialRows): page with the existing cursor;
359
- // * server returned the complete set but it is too big: page the same set at
360
- // a smaller row_limit via a fresh cursor (offered here as `next`) or narrow
361
- // with HAVING.
362
- // "page with the cursor" is never suggested unless a cursor is actually given.
363
- const remedy = partialRows
364
- ? " page with the cursor for the remaining rows"
365
- : repage
366
- ? " re-run with the returned cursor to page the full set at a smaller row_limit, or add a HAVING filter to narrow the groups"
367
- : " re-run with a lower row_limit to page the full set, or add a HAVING filter to narrow the groups";
368
- const capNote = (shown) => returned !== undefined && shown < returned
369
- ? ` [MCP showed ${shown} of ${returned} rows — over the ${HARD_OUTPUT_CHARS}-char output budget]`
370
- : " [hard-capped for MCP output]";
371
- for (const cap of [200, 100, 50, 25, 10, 5, 3]) {
372
- const hardState = { truncated: true };
373
- data = truncateValue(value, { maxItems: cap, maxString: 160 }, hardState);
374
- const shown = returned !== undefined ? Math.min(cap, returned) : cap;
375
- const hardNext = partialRows
376
- ? next
377
- : repage
378
- ? continuationNext(repage, shown, Math.max(1, shown))
315
+ // A query page is data, not a preview: keep entire terms/values and page
316
+ // the rows themselves to fit the wire budget. The cursor must advance by
317
+ // what the caller actually received, even inside a partial server page.
318
+ const source = value;
319
+ const results = source?.results;
320
+ const key = Array.isArray(results?.bindings)
321
+ ? "bindings"
322
+ : Array.isArray(source?.groups) && source.groups.length > 0
323
+ ? "groups"
324
+ : Array.isArray(source?.solutions)
325
+ ? "solutions"
379
326
  : undefined;
380
- result = {
381
- summary: rowText
382
- ? `${label}: ${rowText}${serverText}${capNote(shown)} —${remedy}`
383
- : `${label}${capNote(shown)} —${remedy}`,
384
- data,
385
- counts: countsFor(value),
386
- row_page: rowPage,
387
- rows_shown: returned !== undefined ? shown : undefined,
388
- truncated: true,
389
- next: hardNext,
327
+ const rows = key === "bindings" ? results?.bindings : key ? source[key] : undefined;
328
+ if (!rowPage || !repage || !Array.isArray(rows)) {
329
+ return { ...envelope(label, value, detailArg, next), row_page: rowPage };
330
+ }
331
+ if (rows.length !== rowPage.returned) {
332
+ throw new Error("Query row count does not match its page; refusing a cursor that could skip rows.");
333
+ }
334
+ const flags = serverTruncationFlags(value);
335
+ const build = (count) => {
336
+ const hasMore = count < rows.length || rowPage.has_more;
337
+ const page = {
338
+ ...rowPage,
339
+ returned: count,
340
+ has_more: hasMore,
341
+ next_offset: hasMore ? rowPage.offset + count : undefined,
390
342
  };
391
- text = JSON.stringify(result, null, 2);
392
- if (text.length <= HARD_OUTPUT_CHARS)
393
- return result;
394
- }
395
- return {
396
- summary: rowText
397
- ? `${label}: ${rowText}${serverText}${capNote(0)} —${remedy}`
398
- : `${label}${capNote(0)} —${remedy}`,
399
- data: {
400
- note: "The response was too large for the MCP tool result even at the minimum row cap. Page with a smaller row_limit or add a HAVING filter to narrow the groups.",
401
- preview: text.slice(0, 20_000),
402
- },
403
- counts: countsFor(value),
404
- row_page: rowPage,
405
- rows_shown: returned !== undefined ? 0 : undefined,
406
- truncated: true,
407
- next: partialRows
408
- ? next
409
- : repage
410
- ? continuationNext(repage, 0, 1)
343
+ const selected = rows.slice(0, count);
344
+ const data = key === "bindings"
345
+ ? { ...source, results: { ...results, bindings: selected } }
346
+ : { ...source, [key]: selected, row_page: page };
347
+ return {
348
+ summary: `${label}: returned ${count}${count < page.total ? ` of ${page.total}` : ""} rows${flags.length ? ` [server-truncated: ${flags.join(", ")}]` : ""}${count < rows.length ? " [byte-bounded page; continue with the cursor]" : ""}`,
349
+ data,
350
+ counts: countsFor(data),
351
+ row_page: page,
352
+ rows_shown: count < rows.length ? count : undefined,
353
+ truncated: hasMore || flags.length > 0 || undefined,
354
+ next: hasMore
355
+ ? continuationNext(repage, page.offset + count, repage.row_limit)
411
356
  : undefined,
357
+ };
412
358
  };
359
+ // Leave room for tool-level normalization notes added by the caller. Count
360
+ // UTF-8 bytes, including multibyte literals, not JavaScript code units.
361
+ const fits = (page) => Buffer.byteLength(JSON.stringify(page, null, 2), "utf8") <=
362
+ HARD_OUTPUT_CHARS - 2048;
363
+ const full = build(rows.length);
364
+ if (fits(full))
365
+ return full;
366
+ let lo = 0;
367
+ let hi = rows.length;
368
+ while (lo < hi) {
369
+ const mid = Math.ceil((lo + hi) / 2);
370
+ if (fits(build(mid)))
371
+ lo = mid;
372
+ else
373
+ hi = mid - 1;
374
+ }
375
+ if (lo === 0) {
376
+ throw new Error("One query row exceeds the MCP output budget. Project fewer fields or use the SPARQL HTTP API for this value; no rows were skipped.");
377
+ }
378
+ return build(lo);
413
379
  }
414
380
  export function toolResult(value) {
415
381
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "MCP server for little big brain — graph and hybrid search tools for agents",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {