From 6aa7d4f3bdd22ca9fef1643d7aef30991a0312b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 16:07:58 +0000 Subject: [PATCH 1/2] feat(mcp-server): output shaping & truncation framework (Prompt 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a centralized formatter enforcing bounded, valid tool output (spec §9.3). - src/tools/output.ts: shapeListResult caps the serialized payload (MAX_TOOL_RESULT_BYTES) by dropping whole trailing items via binary search (never invalid JSON), reports returnedCount/totalCount/truncated, and adds a continuation hint (payload_size vs result_cap) when not all results are shown - retrofit all three tools (charges, tags/tax-categories, balance report) to build their results through the shared formatter; per-tool ad-hoc truncated flags removed in favor of the unified fields - unit tests for within-limit, result-cap continuation, payload-size guard (whole-item drops, valid JSON, prefix preserved, zero-fit), plus updated tool tests for the new output shape Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --- packages/mcp-server/README.md | 5 + .../src/tools/__tests__/charges.test.ts | 7 +- .../src/tools/__tests__/lookups.test.ts | 17 ++- .../src/tools/__tests__/output.test.ts | 91 ++++++++++++++ .../src/tools/__tests__/reports.test.ts | 22 +++- packages/mcp-server/src/tools/charges.ts | 21 ++-- packages/mcp-server/src/tools/lookups.ts | 65 +++++----- packages/mcp-server/src/tools/output.ts | 117 ++++++++++++++++++ packages/mcp-server/src/tools/reports.ts | 26 ++-- 9 files changed, 298 insertions(+), 73 deletions(-) create mode 100644 packages/mcp-server/src/tools/__tests__/output.test.ts create mode 100644 packages/mcp-server/src/tools/output.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 5d75dde4c..8f9c0e9bd 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -29,6 +29,11 @@ failures to a tool result with `isError` and a `{ code, message, correlationId, payload (spec §10.2): `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERROR`, `TIMEOUT_ERROR`, `INTERNAL_ERROR`. +List-producing tools build their output through a shared formatter (`src/tools/output.ts`) that caps +the serialized payload (dropping whole trailing items — never invalid JSON), reports `returnedCount` +/ `totalCount` / `truncated`, and attaches a `continuation` hint whenever not all results were +returned (an upstream cap or the payload-size guard). + - **`accounter_search_charges`** — read-only charges search/browse within the caller's authorized businesses. Optional `businessIds` (subset of memberships), `fromDate`/`toDate` (bounded to 366 days), `tags`, `freeText`, and `flow` (`ALL`/`INCOME`/`EXPENSE`), with bounded pagination diff --git a/packages/mcp-server/src/tools/__tests__/charges.test.ts b/packages/mcp-server/src/tools/__tests__/charges.test.ts index 44b31fc2f..a047257bd 100644 --- a/packages/mcp-server/src/tools/__tests__/charges.test.ts +++ b/packages/mcp-server/src/tools/__tests__/charges.test.ts @@ -66,7 +66,9 @@ describe('searchChargesTool — successful read', () => { expect(result.isError).toBeUndefined(); const structured = result.structuredContent as { charges: Array<{ id: string; amount: { value: number } | null }>; - pagination: { totalRecords: number; hasNextPage: boolean }; + pagination: { hasNextPage: boolean }; + totalCount: number; + truncated: boolean; }; expect(structured.charges).toEqual([ { @@ -76,7 +78,8 @@ describe('searchChargesTool — successful read', () => { date: '2026-01-05', }, ]); - expect(structured.pagination.totalRecords).toBe(1); + expect(structured.totalCount).toBe(1); + expect(structured.truncated).toBe(false); expect(structured.pagination.hasNextPage).toBe(false); }); diff --git a/packages/mcp-server/src/tools/__tests__/lookups.test.ts b/packages/mcp-server/src/tools/__tests__/lookups.test.ts index 2a84af5a4..cac6bfaf6 100644 --- a/packages/mcp-server/src/tools/__tests__/lookups.test.ts +++ b/packages/mcp-server/src/tools/__tests__/lookups.test.ts @@ -57,17 +57,26 @@ describe('listTagsTool', () => { it('filters by nameContains (case-insensitive)', async () => { const result = await runTool(listTagsTool, client(), authContext(['b1']), { nameContains: 'an' }); - const structured = result.structuredContent as { tags: Array<{ name: string }>; total: number }; + const structured = result.structuredContent as { + tags: Array<{ name: string }>; + totalCount: number; + }; expect(structured.tags.map(t => t.name)).toEqual(['Banana']); - expect(structured.total).toBe(1); + expect(structured.totalCount).toBe(1); }); it('caps results and flags truncation', async () => { const result = await runTool(listTagsTool, client(), authContext(['b1']), { limit: 2 }); - const structured = result.structuredContent as { tags: unknown[]; total: number; truncated: boolean }; + const structured = result.structuredContent as { + tags: unknown[]; + totalCount: number; + truncated: boolean; + continuation: { reason: string }; + }; expect(structured.tags).toHaveLength(2); - expect(structured.total).toBe(3); + expect(structured.totalCount).toBe(3); expect(structured.truncated).toBe(true); + expect(structured.continuation.reason).toBe('result_cap'); }); it('enforces business scope (denies a caller with no memberships)', async () => { diff --git a/packages/mcp-server/src/tools/__tests__/output.test.ts b/packages/mcp-server/src/tools/__tests__/output.test.ts new file mode 100644 index 000000000..72516f4d5 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/output.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_TOOL_RESULT_BYTES, shapeListResult } from '../output.js'; + +function bytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +describe('shapeListResult — within limits', () => { + it('returns all items, untruncated, with counts', () => { + const result = shapeListResult({ items: [{ a: 1 }, { a: 2 }], itemsKey: 'things' }); + const structured = result.structuredContent as { + things: unknown[]; + returnedCount: number; + totalCount: number; + truncated: boolean; + continuation?: unknown; + }; + expect(structured.things).toHaveLength(2); + expect(structured.returnedCount).toBe(2); + expect(structured.totalCount).toBe(2); + expect(structured.truncated).toBe(false); + expect(structured.continuation).toBeUndefined(); + }); + + it('merges extra fields and uses a custom summary', () => { + const result = shapeListResult({ + items: [{ a: 1 }], + itemsKey: 'things', + extra: { page: 2 }, + summarize: (shown, total) => `${shown}/${total}`, + }); + const structured = result.structuredContent as { page: number }; + expect(structured.page).toBe(2); + expect(result.content[0].text).toBe('1/1'); + }); +}); + +describe('shapeListResult — result cap (total > items)', () => { + it('marks truncated with a result_cap continuation when more exist upstream', () => { + const result = shapeListResult({ items: [{ a: 1 }], itemsKey: 'things', total: 10 }); + const structured = result.structuredContent as { + truncated: boolean; + continuation: { reason: string; returnedCount: number; totalCount: number }; + }; + expect(structured.truncated).toBe(true); + expect(structured.continuation.reason).toBe('result_cap'); + expect(structured.continuation.returnedCount).toBe(1); + expect(structured.continuation.totalCount).toBe(10); + }); +}); + +describe('shapeListResult — payload-size guard', () => { + it('drops whole trailing items to fit the byte cap and stays valid JSON', () => { + // Each item is ~1KB; cap forces dropping some. + const items = Array.from({ length: 100 }, (_, i) => ({ id: i, blob: 'x'.repeat(1000) })); + const maxBytes = 20_000; + const result = shapeListResult({ items, itemsKey: 'rows', maxBytes }); + + const structured = result.structuredContent as { + rows: Array<{ id: number }>; + returnedCount: number; + totalCount: number; + truncated: boolean; + continuation: { reason: string }; + }; + // Fits under the cap. + expect(bytes(structured)).toBeLessThanOrEqual(maxBytes); + // Dropped some, but kept whole items (a prefix of the input). + expect(structured.rows.length).toBeGreaterThan(0); + expect(structured.rows.length).toBeLessThan(100); + expect(structured.rows.map(r => r.id)).toEqual( + Array.from({ length: structured.rows.length }, (_, i) => i), + ); + expect(structured.returnedCount).toBe(structured.rows.length); + expect(structured.totalCount).toBe(100); + expect(structured.truncated).toBe(true); + expect(structured.continuation.reason).toBe('payload_size'); + }); + + it('exposes a sane default byte cap', () => { + expect(MAX_TOOL_RESULT_BYTES).toBeGreaterThan(0); + }); + + it('returns zero items when even a single item cannot fit', () => { + const items = [{ blob: 'x'.repeat(10_000) }]; + const result = shapeListResult({ items, itemsKey: 'rows', maxBytes: 100 }); + const structured = result.structuredContent as { rows: unknown[]; returnedCount: number }; + expect(structured.rows).toHaveLength(0); + expect(structured.returnedCount).toBe(0); + }); +}); diff --git a/packages/mcp-server/src/tools/__tests__/reports.test.ts b/packages/mcp-server/src/tools/__tests__/reports.test.ts index b209a5307..e163d6d0c 100644 --- a/packages/mcp-server/src/tools/__tests__/reports.test.ts +++ b/packages/mcp-server/src/tools/__tests__/reports.test.ts @@ -68,9 +68,13 @@ describe('balanceReportTool — valid report', () => { expect(result.isError).toBeUndefined(); expect((sent as { variables: { ownerId: string } }).variables.ownerId).toBe('b1'); - const structured = result.structuredContent as { rows: unknown[]; rowCount: number; businessId: string }; + const structured = result.structuredContent as { + rows: unknown[]; + totalCount: number; + businessId: string; + }; expect(structured.businessId).toBe('b1'); - expect(structured.rowCount).toBe(1); + expect(structured.totalCount).toBe(1); expect(structured.rows).toEqual([ { id: 't1', @@ -124,9 +128,17 @@ describe('balanceReportTool — oversized results', () => { const many = Array.from({ length: MAX_REPORT_ROWS + 5 }, (_, i) => row(`t${i}`)); const client = clientReturning(many); const result = await run(client, authContext(['b1']), validArgs); - const structured = result.structuredContent as { rows: unknown[]; rowCount: number; truncated: boolean }; - expect(structured.rows).toHaveLength(MAX_REPORT_ROWS); - expect(structured.rowCount).toBe(MAX_REPORT_ROWS + 5); + const structured = result.structuredContent as { + rows: unknown[]; + totalCount: number; + truncated: boolean; + }; + // The in-tool row cap bounds items before serialization; the shared + // payload guard may trim further. Either way the result is truncated and + // reports the true upstream total. + expect(structured.rows.length).toBeGreaterThan(0); + expect(structured.rows.length).toBeLessThanOrEqual(MAX_REPORT_ROWS); + expect(structured.totalCount).toBe(MAX_REPORT_ROWS + 5); expect(structured.truncated).toBe(true); }); }); diff --git a/packages/mcp-server/src/tools/charges.ts b/packages/mcp-server/src/tools/charges.ts index eea6258e7..40983d962 100644 --- a/packages/mcp-server/src/tools/charges.ts +++ b/packages/mcp-server/src/tools/charges.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ToolInputError } from './execute.js'; +import { shapeListResult } from './output.js'; import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; /** @@ -165,19 +166,19 @@ async function handler( page: pageInfo.currentPage ?? input.page, pageSize: pageInfo.pageSize ?? input.pageSize, totalPages: pageInfo.totalPages, - totalRecords: pageInfo.totalRecords, hasNextPage: (pageInfo.currentPage ?? input.page) < pageInfo.totalPages, }; - const summary = - charges.length === 0 - ? 'No charges matched the given filters.' - : `Found ${pagination.totalRecords} charge(s); showing page ${pagination.page} of ${pagination.totalPages} (${charges.length} on this page).`; - - return { - content: [{ type: 'text', text: summary }], - structuredContent: { charges, pagination }, - }; + return shapeListResult({ + items: charges, + itemsKey: 'charges', + total: pageInfo.totalRecords, + extra: { pagination }, + summarize: (shown, total) => + total === 0 + ? 'No charges matched the given filters.' + : `Found ${total} charge(s); showing ${shown} on page ${pagination.page} of ${pagination.totalPages}.`, + }); } export const searchChargesTool: ToolDefinition = { diff --git a/packages/mcp-server/src/tools/lookups.ts b/packages/mcp-server/src/tools/lookups.ts index d786179f0..80d8236be 100644 --- a/packages/mcp-server/src/tools/lookups.ts +++ b/packages/mcp-server/src/tools/lookups.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { shapeListResult } from './output.js'; import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; /** @@ -38,19 +39,17 @@ function byNameThenId(a: { name: string; id: string }, b: { name: string; id: st ); } -function applyFilterSortCap( +function filterSortCap( rows: T[], search: string | undefined, max: number, -): { rows: T[]; total: number; truncated: boolean } { +): { rows: T[]; total: number } { const needle = search?.toLowerCase(); const filtered = needle ? rows.filter(row => row.name.toLowerCase().includes(needle)) : rows; const sorted = [...filtered].sort(byNameThenId); - return { - rows: sorted.slice(0, max), - total: filtered.length, - truncated: filtered.length > max, - }; + // `total` is the full match count; the byte-guard/continuation is applied by + // shapeListResult against this total. + return { rows: sorted.slice(0, max), total: filtered.length }; } // --------------------------------------------------------------------------- @@ -85,26 +84,20 @@ async function listTagsHandler( { correlationId: context.correlationId, authorization: context.authorization }, ); - const { rows, total, truncated } = applyFilterSortCap( - data.allTags, - input.nameContains, - input.limit, - ); + const { rows, total } = filterSortCap(data.allTags, input.nameContains, input.limit); const tags = rows.map(tag => ({ id: tag.id, name: tag.name, namePath: tag.namePath ?? [tag.name], })); - return { - content: [ - { - type: 'text', - text: `Found ${total} ${total === 1 ? 'tag' : 'tags'}${truncated ? ' (truncated)' : ''}.`, - }, - ], - structuredContent: { tags, total, truncated }, - }; + return shapeListResult({ + items: tags, + itemsKey: 'tags', + total, + summarize: (_shown, count, truncated) => + `Found ${count} ${count === 1 ? 'tag' : 'tags'}${truncated ? ' (truncated)' : ''}.`, + }); } export const listTagsTool: ToolDefinition = { @@ -158,23 +151,21 @@ async function listTaxCategoriesHandler( ? data.taxCategories.filter(category => category.isActive) : data.taxCategories; // `rows` are already `RawTaxCategory` with exactly the fields we expose. - const { - rows: taxCategories, + const { rows: taxCategories, total } = filterSortCap( + activeFiltered, + input.nameContains, + input.limit, + ); + + return shapeListResult({ + items: taxCategories, + itemsKey: 'taxCategories', total, - truncated, - } = applyFilterSortCap(activeFiltered, input.nameContains, input.limit); - - return { - content: [ - { - type: 'text', - text: `Found ${total} tax ${total === 1 ? 'category' : 'categories'}${ - truncated ? ' (truncated)' : '' - }.`, - }, - ], - structuredContent: { taxCategories, total, truncated }, - }; + summarize: (_shown, count, truncated) => + `Found ${count} tax ${count === 1 ? 'category' : 'categories'}${ + truncated ? ' (truncated)' : '' + }.`, + }); } export const listTaxCategoriesTool: ToolDefinition = { diff --git a/packages/mcp-server/src/tools/output.ts b/packages/mcp-server/src/tools/output.ts new file mode 100644 index 000000000..c893c3692 --- /dev/null +++ b/packages/mcp-server/src/tools/output.ts @@ -0,0 +1,117 @@ +import type { ToolResult } from './registry.js'; + +/** + * Centralized output shaping and truncation for all tools (spec §9.3). + * + * List-producing tools build their result through {@link shapeListResult}. It + * enforces a maximum serialized payload size by dropping whole trailing items + * (never cutting a JSON structure mid-object), reports how many items were + * returned versus available, and attaches a continuation hint whenever the + * caller is not seeing everything (either an upstream cap or the payload guard). + */ + +/** Max serialized size of a tool result's structured content, in bytes. */ +export const MAX_TOOL_RESULT_BYTES = 60_000; + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +/** Continuation hint returned when results are truncated. */ +export interface ContinuationHint { + reason: 'payload_size' | 'result_cap'; + returnedCount: number; + totalCount: number; + hint: string; +} + +export interface ShapeListParams { + /** Items to return (already filtered/normalized; may itself be upstream-capped). */ + items: readonly T[]; + /** Key under which the items are placed in structured content (e.g. `charges`). */ + itemsKey: string; + /** + * Total available upstream. Defaults to `items.length`. When greater than the + * number ultimately returned, the result is marked truncated with a hint. + */ + total?: number; + /** Extra domain fields merged into structured content (pagination, period, …). */ + extra?: Record; + /** Build the human-readable summary line. */ + summarize?: (shown: number, total: number, truncated: boolean) => string; + /** Override the byte cap (mainly for tests). */ + maxBytes?: number; +} + +function defaultSummary(shown: number, total: number, truncated: boolean): string { + if (total === 0) { + return 'No results.'; + } + return `Returning ${shown} of ${total} result(s)${truncated ? ' (truncated)' : ''}.`; +} + +/** + * Largest prefix length `n` of `items` whose shaped structured content fits + * within `maxBytes`. Uses binary search so it is deterministic and fast. + */ +function fittingCount( + build: (n: number) => Record, + itemCount: number, + maxBytes: number, +): number { + if (byteLength(JSON.stringify(build(itemCount))) <= maxBytes) { + return itemCount; + } + let low = 0; + let high = itemCount; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (byteLength(JSON.stringify(build(mid))) <= maxBytes) { + low = mid; + } else { + high = mid - 1; + } + } + return low; +} + +/** + * Shape a list of items into a bounded, valid {@link ToolResult}. Never emits + * invalid JSON — items are dropped whole from the end when the payload guard + * trips. + */ +export function shapeListResult(params: ShapeListParams): ToolResult { + const maxBytes = params.maxBytes ?? MAX_TOOL_RESULT_BYTES; + const total = params.total ?? params.items.length; + + const structuredFor = (shown: number): Record => { + const truncated = shown < total; + const structured: Record = { + [params.itemsKey]: params.items.slice(0, shown), + ...params.extra, + returnedCount: shown, + totalCount: total, + truncated, + }; + if (truncated) { + const continuation: ContinuationHint = { + reason: shown < params.items.length ? 'payload_size' : 'result_cap', + returnedCount: shown, + totalCount: total, + hint: 'Not all results were returned. Narrow your filters or request a smaller/next page to see more.', + }; + structured.continuation = continuation; + } + return structured; + }; + + const shown = fittingCount(structuredFor, params.items.length, maxBytes); + const structured = structuredFor(shown); + const truncated = structured.truncated === true; + const summarize = params.summarize ?? defaultSummary; + + return { + content: [{ type: 'text', text: summarize(shown, total, truncated) }], + structuredContent: structured, + }; +} diff --git a/packages/mcp-server/src/tools/reports.ts b/packages/mcp-server/src/tools/reports.ts index 9d377d794..5074b9bd9 100644 --- a/packages/mcp-server/src/tools/reports.ts +++ b/packages/mcp-server/src/tools/reports.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ToolInputError } from './execute.js'; +import { shapeListResult } from './output.js'; import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; /** @@ -100,7 +101,6 @@ async function handler( // Defend against a null/absent list from a nullable upstream field. const all = data.transactionsForBalanceReport ?? []; - const truncated = all.length > MAX_REPORT_ROWS; const rows = all.slice(0, MAX_REPORT_ROWS).map(row => ({ id: row.id, chargeId: row.chargeId, @@ -114,24 +114,20 @@ async function handler( }, })); - return { - content: [ - { - type: 'text', - text: `Balance report for ${input.fromDate}–${input.toDate}: ${all.length} transaction(s)${ - truncated ? ` (showing first ${MAX_REPORT_ROWS})` : '' - }.`, - }, - ], - structuredContent: { + return shapeListResult({ + items: rows, + itemsKey: 'rows', + total: all.length, + extra: { reportType: input.reportType, businessId: ownerId, period: { fromDate: input.fromDate, toDate: input.toDate }, - rows, - rowCount: all.length, - truncated, }, - }; + summarize: (shown, total) => + `Balance report for ${input.fromDate}–${input.toDate}: ${total} transaction(s)${ + shown < total ? ` (showing ${shown})` : '' + }.`, + }); } export const balanceReportTool: ToolDefinition = { From 4c693881d837383533a95d2565d098a7df362101 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 11:59:09 +0000 Subject: [PATCH 2/2] fix(mcp-server): harden shapeListResult output shaping Address review feedback on output.ts: - Normalize `total` to never be less than `items.length`, so a caller passing a too-small `total` can't produce `totalCount < returnedCount` or a wrong `truncated` flag. - Spread `extra` before the framework-owned keys so a colliding `extra` key can never overwrite the items array or the counts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --- packages/mcp-server/src/tools/output.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/tools/output.ts b/packages/mcp-server/src/tools/output.ts index c893c3692..d65b4ee62 100644 --- a/packages/mcp-server/src/tools/output.ts +++ b/packages/mcp-server/src/tools/output.ts @@ -82,13 +82,18 @@ function fittingCount( */ export function shapeListResult(params: ShapeListParams): ToolResult { const maxBytes = params.maxBytes ?? MAX_TOOL_RESULT_BYTES; - const total = params.total ?? params.items.length; + // Never let a caller-supplied `total` fall below the number of items on hand; + // otherwise `totalCount` could be < `returnedCount` and `truncated` would be + // computed incorrectly. + const total = Math.max(params.total ?? params.items.length, params.items.length); const structuredFor = (shown: number): Record => { const truncated = shown < total; + // Spread `extra` first so the framework-owned keys below always win a name + // collision — `extra` can never clobber the items array or the counts. const structured: Record = { - [params.itemsKey]: params.items.slice(0, shown), ...params.extra, + [params.itemsKey]: params.items.slice(0, shown), returnedCount: shown, totalCount: total, truncated,