diff --git a/package.json b/package.json index 7b398b6..05ed511 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..04d7316 --- /dev/null +++ b/src/format.ts @@ -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 { + 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 { + 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[] { + const columns: string[] = [] + const seen = new Set() + 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 { + 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[] + const count = `${rows.length} ${rows.length === 1 ? 'row' : 'rows'}.` + return `${count}\n${renderTable(rows)}` +} diff --git a/src/index.ts b/src/index.ts index 0098ccc..69a54f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -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) }], } } diff --git a/test/format.mjs b/test/format.mjs new file mode 100644 index 0000000..256f83a --- /dev/null +++ b/test/format.mjs @@ -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)