Skip to content

Render list results as a table instead of pretty-printed JSON - #35

Merged
theodor90 merged 1 commit into
mainfrom
compact-tool-output
Aug 25, 2026
Merged

theodor90 merged 1 commit into
mainfrom
compact-tool-output

Conversation

@theodor90

Copy link
Copy Markdown
Owner

Every tool returned JSON.stringify(data, null, 2). For the list endpoints that is a bad trade: a 100-row get_transactions response is ~2,100 lines and ~15k tokens, and roughly 60% of that is the same nineteen key names repeated a hundred times. The rows are what the model needs; the repetition is what it pays for.

A pipe table states each key once, in the header:

pretty JSON   60,302 chars   ~15,076 tokens   2,102 lines
table         22,145 chars    ~5,536 tokens     103 lines

2.7x. test/format.mjs prints that figure on every run, so it stays honest as the response shape changes.

That saving is spent from the caller's context window rather than ours, and MCP is this product's highest-reach channel (~2.7x SDK downloads), so it is worth being careful about in both directions.

Two deliberate limits

No columns are dropped. A curated column set per tool would compress further, but a field the model cannot see is a field the user cannot ask about, and the tools are generated from an evolving OpenAPI spec — a hand-maintained column list would rot silently.

Rows carrying populated nested collections keep their old JSON output exactly. get_signals returns each signal with its transactions[] attached, and those transactions are the substance of the answer; flattening them into a cell would either explode the table or lose them.

Numbers below 1 keep four decimals

Returns and hit rates are stored as fractions — the generated spec says so on every scored endpoint ("all return fields are stored as FRACTIONS — 0.05 means +5%"). Rounding those to 2dp is wrong in a way that is easy to miss, because the reasoning that money reads fine at 2dp does not transfer:

field true value at 2dp reads as
avgReturn3m 0.109 0.11 +11% not +10.9%
avgReturn3m 0.0234 0.02 +2% not +2.34%
avgReturn3m -0.003 -0.00 nonsense

/v1/insiders/leaderboard returns an array of flat rows carrying exactly these fields, so it takes the table path — the whole ranking column would have collapsed into ties. Money and prices sit above 1 and read fine at two.

Not used: structuredContent

It cannot hold a bare array (the spec types it as an object), and a tool that sets it SHOULD also serialize the same payload into a text block — so it duplicates the data rather than replacing it, which is the opposite of the point. Declaring the matching outputSchema would also make the SDK hard-fail any tool whose response drifts from it, and these shapes come from a live API.

Verification

wrapResult is the single choke point for every tool, so this applies uniformly. 130 tests pass (mcp-test 65, client-errors 27, format 35, generated-paths 3); tsc --noEmit clean; build clean.

Cell truncation at 80 characters is the one remaining lossy edge — no current field is close to it, but it is silent when it does fire.

Every tool returned JSON.stringify(data, null, 2). For the list endpoints that
is a bad trade: a 100-row get_transactions response is ~2,100 lines and ~15k
tokens, and roughly 60% of that is the same nineteen key names repeated a
hundred times. The rows are what the model needs; the repetition is what it
pays for. A pipe table states each key once, in the header — 60,302 chars down
to 22,145, which test/format.mjs prints on every run so the figure stays honest
as the shape changes.

No columns are dropped. A curated column set per tool would compress further,
but a field the model cannot see is a field the user cannot ask about, and the
tools are generated from an evolving OpenAPI spec, so a hand-maintained column
list would rot silently. Rows carrying populated nested collections keep their
old JSON output exactly: get_signals returns each signal with its
transactions[] attached, and those are the substance of the answer.

Numbers below 1 keep four decimals. Returns and hit rates are stored as
fractions — the generated spec says so on every scored endpoint — so 2dp would
read 0.0234 as +2%, print a -0.30% return as "-0.00", and collapse the
leaderboard's ranking column into ties. Money and prices sit above 1 and read
fine at two.

structuredContent is deliberately not used: it cannot hold a bare array, and a
tool that sets it should also serialize the same payload as text, so it would
duplicate the data rather than replace it.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@theodor90

Copy link
Copy Markdown
Owner Author

Review before merge — the one caveat in the description is now measured rather than open.

MAX_CELL_CHARS = 80 truncates silently. Against production:

field over 80 chars total
insider name 41 75,833 0.05%
security title 0 1,345,485 0.00%
company name 4 20,313 0.02%

The 41 are trust and pension-scheme entities filing as reporting owners, where 80 characters is still more than enough to identify the filer. SecurityTitle is varchar(200) so it could have been the problem, and isn't — nothing in 1.3M rows comes close.

Also re-verified after rebasing onto current main: build clean, 130 tests pass, and this does not conflict with #36 (they touch different lines of package.json and test/mcp-test.mjs).

@theodor90
theodor90 merged commit 24babd5 into main Aug 25, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant