Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/mcp-server/src/tools/__tests__/charges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand All @@ -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);
});

Expand Down
17 changes: 13 additions & 4 deletions packages/mcp-server/src/tools/__tests__/lookups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
91 changes: 91 additions & 0 deletions packages/mcp-server/src/tools/__tests__/output.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 17 additions & 5 deletions packages/mcp-server/src/tools/__tests__/reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});
});
Expand Down
21 changes: 11 additions & 10 deletions packages/mcp-server/src/tools/charges.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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<typeof searchChargesInput> = {
Expand Down
65 changes: 28 additions & 37 deletions packages/mcp-server/src/tools/lookups.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { shapeListResult } from './output.js';
import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js';

/**
Expand Down Expand Up @@ -38,19 +39,17 @@ function byNameThenId(a: { name: string; id: string }, b: { name: string; id: st
);
}

function applyFilterSortCap<T extends { name: string; id: string }>(
function filterSortCap<T extends { name: string; id: string }>(
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 };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<typeof listTagsInput> = {
Expand Down Expand Up @@ -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<typeof listTaxCategoriesInput> = {
Expand Down
Loading
Loading