Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "node test/mcp-test.mjs && node test/client-errors.mjs && node test/generated-paths.mjs",
"test": "node test/mcp-test.mjs && node test/client-errors.mjs && node test/generated-paths.mjs && node test/format.mjs",
"codegen": "node codegen/generate.mjs",
"codegen:check": "node codegen/check.mjs",
"prepublishOnly": "npm run build"
Expand Down
149 changes: 149 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Renders tool results for the MCP text channel.
*
* Every tool used to return `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 ~16k 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-delimited table states each key once, in the header. Measured on a
* 100-row get_transactions payload with all nineteen fields kept (the figure
* test/format.mjs prints, so it stays honest as the shape changes):
*
* pretty JSON 60,302 chars ~15,076 tokens 2,102 lines
* table 22,145 chars ~5,536 tokens 103 lines
*
* 2.7x. Dropping columns would roughly double that again, and the reasoning
* below is why it is not done. That saving is spent from the caller's context
* window rather than ours, and MCP is this product's highest-reach channel,
* 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 nested collections fall back to JSON. `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. Losing data to save
* tokens is not a trade worth making, so those tools keep their old
* output exactly.
*
* Not used here: `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.
*/

/** Widest a single cell may get before it is truncated with an ellipsis. */
const MAX_CELL_CHARS = 80

/**
* Above this many rows the header is repeated at the bottom. Long tables lose
* their header off the top of a model's attention the same way they do off the
* top of a screen.
*/
const REPEAT_HEADER_AFTER = 40

export function formatCell(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'boolean') return value ? 'yes' : 'no'
if (typeof value === 'number') {
if (!Number.isFinite(value)) return '—'
// Integers stay exact — share counts and CIKs must not pick up decimals.
if (Number.isInteger(value)) return String(value)
// Returns and hit rates are stored as FRACTIONS — the generated spec says so
// on every scored endpoint: 0.0234 is +2.34%. Rounding those to 2dp reads as
// +2%, renders a -0.30% return as the nonsense "-0.00", and collapses a
// leaderboard's ordering into ties. So anything below 1 keeps four decimals;
// money and prices sit above 1 and read fine at two.
return String(Number(value.toFixed(Math.abs(value) < 1 ? 4 : 2)))
}
if (typeof value === 'bigint') return value.toString()
if (typeof value === 'string') {
// A newline inside a cell would break the row/line correspondence the
// whole format depends on.
const collapsed = value.replace(/\s+/g, ' ').trim()
return collapsed === '' ? '—' : truncate(collapsed)
}
// Empty containers are the only nested values that reach here — anything
// non-empty sends the whole payload down the JSON path (see isFlatRow).
if (Array.isArray(value)) return '[]'
return '{}'
}

function truncate(text: string): string {
return text.length > MAX_CELL_CHARS ? `${text.slice(0, MAX_CELL_CHARS - 1)}…` : text
}

/** A pipe in a value would read as a column break. */
function escapeCell(cell: string): string {
return cell.includes('|') ? cell.replace(/\|/g, '/') : cell
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

/**
* True when a row can be rendered as a table line without losing anything.
* Empty arrays and empty objects are fine — they carry no information a cell
* cannot state. Anything populated does not fit in a cell.
*/
function isFlatRow(row: unknown): row is Record<string, unknown> {
if (!isPlainObject(row)) return false
for (const value of Object.values(row)) {
if (Array.isArray(value) && value.length > 0) return false
if (isPlainObject(value) && Object.keys(value).length > 0) return false
}
return true
}

/**
* Column order follows first appearance across the rows rather than the first
* row alone: a nullable field the API omits from early rows still gets a
* column instead of being silently dropped.
*/
function collectColumns(rows: Record<string, unknown>[]): string[] {
const columns: string[] = []
const seen = new Set<string>()
for (const row of rows) {
for (const key of Object.keys(row)) {
if (seen.has(key)) continue
seen.add(key)
columns.push(key)
}
}
return columns
}

export function renderTable(rows: Record<string, unknown>[]): string {
const columns = collectColumns(rows)
const header = columns.join(' | ')
const lines = [header]
for (const row of rows) {
lines.push(columns.map((column) => escapeCell(formatCell(row[column]))).join(' | '))
}
if (rows.length > REPEAT_HEADER_AFTER) lines.push(header)
return lines.join('\n')
}

/**
* Render a tool result for the text channel: a table when the payload is a
* list of flat rows, the previous pretty JSON otherwise.
*/
export function formatToolResult(data: unknown): string {
if (!Array.isArray(data)) return JSON.stringify(data, null, 2)
if (data.length === 0) return 'No results.'
if (!data.every(isFlatRow)) return JSON.stringify(data, null, 2)

const rows = data as Record<string, unknown>[]
const count = `${rows.length} ${rows.length === 1 ? 'row' : 'rows'}.`
return `${count}\n${renderTable(rows)}`
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { Form4ApiClient, Form4ApiError } from './client.js'
import { formatToolResult } from './format.js'
import { getTransactionsSchema, getTransactions } from './tools/transactions.js'
import { getRecentFilingsSchema, getFilingSchema, getRecentFilings, getFiling } from './tools/filings.js'
import { getInsiderProfileSchema, getInsiderTransactionsSchema, getInsiderProfile, getInsiderTransactions } from './tools/insiders.js'
Expand Down Expand Up @@ -50,7 +51,7 @@ const server = new McpServer(

function wrapResult(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
content: [{ type: 'text', text: formatToolResult(data) }],
}
}

Expand Down
140 changes: 140 additions & 0 deletions test/format.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Unit tests for the MCP text-channel formatter.
//
// The integration test (mcp-test.mjs) checks that tools respond; it cannot
// check what the response reads like, because that needs a live key and a
// deterministic payload. These run the formatter directly over fixtures that
// stand in for the real response shapes — flat lists, lists carrying nested
// collections, single objects, and the empty case.
//
// Run: node test/format.mjs (requires npm run build first)

import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const { formatToolResult, formatCell, renderTable } = require('../dist/format.js')

let passed = 0
let failed = 0

function assert(condition, label) {
if (condition) {
console.log(` ✓ ${label}`)
passed++
} else {
console.error(` ✗ ${label}`)
failed++
}
}

// A row shaped like the real Transaction interface.
const txn = (over = {}) => ({
transactionId: 184920331,
accessionNumber: '0000320193-26-000042',
filedAt: '2026-08-19T20:31:00Z',
transactionDate: '2026-08-17',
transactionCode: 'P',
sharesAmount: 12500,
pricePerShare: 231.44,
totalValue: 2893000,
isOpenMarket: true,
is10b5Plan: false,
companyName: 'Apple Inc.',
ticker: 'AAPL',
companyCik: '0000320193',
insiderName: 'Cook Timothy D',
insiderCik: '0001214128',
insiderTitle: 'Chief Executive Officer',
isDirector: false,
isOfficer: true,
is10PctOwner: false,
...over,
})

console.log('\nCell formatting')
assert(formatCell(null) === '—', 'null renders as an em dash, not "null"')
assert(formatCell(undefined) === '—', 'undefined renders as an em dash')
assert(formatCell('') === '—', 'empty string renders as an em dash')
assert(formatCell(true) === 'yes' && formatCell(false) === 'no', 'booleans read as yes/no')
assert(formatCell(12500) === '12500', 'integers stay exact — no decimals on share counts')
assert(formatCell(231.4444) === '231.44', 'money-scale numbers round to 2dp')

// Returns and hit rates are stored as FRACTIONS (0.0234 is +2.34%). At 2dp the
// leaderboard's whole ranking column collapses: +2.34% and +1.51% both print
// "0.02", and a -0.30% return prints "-0.00".
assert(formatCell(0.0234) === '0.0234', 'a fractional return keeps the digits that carry its meaning')
assert(formatCell(0.109) === '0.109', 'a 10.9% return does not round to 11%')
assert(formatCell(-0.003) === '-0.003', 'a small negative return keeps its sign and size')
assert(!formatCell(-0.003).startsWith('-0.00') || formatCell(-0.003).length > 5, 'no "-0.00" cells')
assert(formatCell(0.6667) === '0.6667', 'a hit rate keeps four decimals')
assert(formatCell(1234.5678) === '1234.57', 'above 1, two decimals is still enough')
assert(formatCell(NaN) === '—', 'NaN renders as an em dash rather than "NaN"')
assert(formatCell('a\nb c') === 'a b c', 'newlines collapse so a cell cannot break the row')
assert(formatCell('x'.repeat(200)).length === 80, 'long strings truncate to the cell cap')
assert(formatCell([]) === '[]' && formatCell({}) === '{}', 'empty containers render as literals')

console.log('\nTable rendering')
{
const out = renderTable([{ a: 1, b: 'x' }, { a: 2, b: 'y' }])
const lines = out.split('\n')
assert(lines[0] === 'a | b', 'header names each key once')
assert(lines[1] === '1 | x' && lines[2] === '2 | y', 'one line per row')
assert(lines.length === 3, 'no header repeat on a short table')
}
{
// A field absent from the first row must still get a column.
const out = renderTable([{ a: 1 }, { a: 2, b: 'late' }])
assert(out.split('\n')[0] === 'a | b', 'columns collect across all rows, not just the first')
assert(out.split('\n')[1] === '1 | —', 'a row missing that field renders an em dash')
}
{
const out = renderTable([{ a: 'x | y' }])
assert(!out.split('\n')[1].includes('|'), 'a pipe inside a value cannot fake a column break')
}
{
const out = renderTable(Array.from({ length: 41 }, () => ({ a: 1 })))
const lines = out.split('\n')
assert(lines[lines.length - 1] === 'a', 'a long table repeats its header at the bottom')
}

console.log('\nPayload routing')
{
const out = formatToolResult([txn(), txn({ transactionCode: 'S' })])
assert(out.startsWith('2 rows.\n'), 'a list is prefixed with its row count')
assert(out.split('\n').length === 4, 'two rows render as count + header + 2 lines')
assert(out.includes('Cook Timothy D'), 'row values survive into the table')
assert(!out.includes('"insiderName"'), 'key names are not repeated per row')
}
assert(formatToolResult([]) === 'No results.', 'an empty list says so instead of printing []')
assert(formatToolResult([txn()]).startsWith('1 row.'), 'the row count is singular at one row')
{
// get_signals attaches each signal's transactions; flattening would lose them.
const signal = { signalId: 1, ticker: 'AAPL', transactions: [txn()] }
const out = formatToolResult([signal])
assert(out.includes('"transactionId"'), 'rows with nested collections fall back to JSON')
assert(JSON.parse(out).length === 1, 'that fallback is valid, parseable JSON')
}
{
// An empty nested array carries nothing, so the table is still lossless.
const out = formatToolResult([{ signalId: 1, transactions: [] }])
assert(out.startsWith('1 row.'), 'an empty nested collection still tables')
}
{
// Single-object endpoints (get_company_overview, verify_setup) are unchanged.
const obj = { cik: '0000320193', name: 'Apple Inc.', ticker: 'AAPL' }
const out = formatToolResult(obj)
assert(out === JSON.stringify(obj, null, 2), 'a single object keeps its pretty JSON')
}
assert(formatToolResult(['a', 'b']).startsWith('['), 'a list of scalars is not a table')

console.log('\nSize')
{
const rows = Array.from({ length: 100 }, () => txn())
const before = JSON.stringify(rows, null, 2).length
const after = formatToolResult(rows).length
const ratio = before / after
console.log(` 100 transactions: ${before.toLocaleString('en-US')} chars -> ${after.toLocaleString('en-US')} chars (${ratio.toFixed(1)}x smaller)`)
assert(ratio > 2.5, 'a 100-row payload shrinks by more than 2.5x')
}

console.log(`\n${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)
Loading