@etsquare/mcp-server-sec 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @etsquare/mcp-server-sec
3
+ * @etsquare/mcp-server-sec v0.3.0
4
4
  * MCP server for SEC Intelligence: search SEC filings,
5
- * resolve company tickers, and execute financial metrics templates.
5
+ * resolve company tickers, execute financial metrics templates,
6
+ * compare companies, and access weekly briefs.
6
7
  */
7
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
10
  import { z } from 'zod';
10
11
  import { ETSquareClient } from './etsquare-client.js';
11
- // Guardrail keywords to detect prompt injection attempts
12
+ // ─── Guardrails ─────────────────────────────────────────────────────────────
12
13
  const GUARDRAIL_KEYWORDS = [
13
14
  'reset guardrails',
14
15
  'disable guardrails',
@@ -40,7 +41,7 @@ function guardrailViolationResponse() {
40
41
  isError: true,
41
42
  };
42
43
  }
43
- // Environment configuration
44
+ // ─── Environment ────────────────────────────────────────────────────────────
44
45
  const baseUrl = process.env.ETSQUARE_BASE_URL || 'https://sec-intelligence-api-4mk2on5fga-uc.a.run.app';
45
46
  const apiKey = process.env.ETSQUARE_API_KEY;
46
47
  const DEBUG = process.env.DEBUG === 'true';
@@ -51,20 +52,277 @@ function log(level, message, data) {
51
52
  const logData = data ? ` ${JSON.stringify(data)}` : '';
52
53
  console.error(`[${timestamp}] [${level.toUpperCase()}] ${message}${logData}`);
53
54
  }
54
- // Validate API key
55
55
  if (!apiKey) {
56
56
  console.error('ERROR: ETSQUARE_API_KEY environment variable is required');
57
57
  process.exit(1);
58
58
  }
59
- // Initialize client
60
59
  const client = new ETSquareClient({ baseUrl, apiKey });
61
- log('info', `ETSquare MCP Server starting with backend: ${baseUrl}`);
62
- // Initialize MCP Server
60
+ log('info', `ETSquare MCP Server v0.3.0 starting with backend: ${baseUrl}`);
61
+ // ─── Item Code Labels ───────────────────────────────────────────────────────
62
+ const ITEM_CODE_LABELS = {
63
+ // 10-K
64
+ '10k_item_1': 'Business Description',
65
+ '10k_item_1a': 'Risk Factors',
66
+ '10k_item_1_a': 'Risk Factors',
67
+ '10k_item_2': 'Properties',
68
+ '10k_item_3': 'Legal Proceedings',
69
+ '10k_item_5': 'Market for Equity',
70
+ '10k_item_6': 'Selected Financial Data',
71
+ '10k_item_7': 'MD&A',
72
+ '10k_item_7a': 'Market Risk Disclosures',
73
+ '10k_item_7_a': 'Market Risk Disclosures',
74
+ '10k_item_8': 'Financial Statements',
75
+ '10k_item_9a': 'Controls & Procedures',
76
+ // 10-Q
77
+ '10q_item_1': 'Financial Statements (Quarterly)',
78
+ '10q_item_1a': 'Risk Factors (Quarterly)',
79
+ '10q_item_1_a': 'Risk Factors (Quarterly)',
80
+ '10q_item_2': 'MD&A (Quarterly)',
81
+ '10q_item_3': 'Market Risk (Quarterly)',
82
+ // 8-K
83
+ '8k_item_1_01': 'Entry into Material Agreement',
84
+ '8k_item_1_02': 'Termination of Material Agreement',
85
+ '8k_item_1_05': 'Material Cybersecurity Incident',
86
+ '8k_item_2_01': 'Completion of Acquisition/Disposition',
87
+ '8k_item_2_02': 'Results of Operations (Earnings)',
88
+ '8k_item_2_03': 'Creation of Direct Financial Obligation',
89
+ '8k_item_2_04': 'Triggering Events (Acceleration/Default)',
90
+ '8k_item_2_05': 'Costs Associated with Exit/Disposal',
91
+ '8k_item_2_06': 'Material Impairments',
92
+ '8k_item_4_01': 'Change in Accountant',
93
+ '8k_item_4_02': 'Non-Reliance on Financial Statements',
94
+ '8k_item_5_01': 'Change in Control',
95
+ '8k_item_5_02': 'Director/Officer Changes',
96
+ '8k_item_5_03': 'Amendments to Articles/Bylaws',
97
+ '8k_item_7_01': 'Regulation FD Disclosure',
98
+ '8k_item_8_01': 'Other Events',
99
+ '8k_item_9_01': 'Financial Statements and Exhibits',
100
+ };
101
+ function mapItemCodeToLabel(itemCode) {
102
+ if (!itemCode)
103
+ return 'Unknown';
104
+ return ITEM_CODE_LABELS[itemCode] || itemCode;
105
+ }
106
+ // Visualization hints are stored in each template's tags_json.visualization_hint
107
+ // in the database and flow through the API. No static registry needed.
108
+ // ─── Column Type / Role Inference Helpers ───────────────────────────────────
109
+ const TIME_FIELDS = new Set([
110
+ 'fiscal_year', 'fiscal_period', 'period', 'period_end',
111
+ 'filing_date', 'quarter', 'year', 'data_as_of',
112
+ ]);
113
+ const GROUP_FIELDS = new Set(['ticker', 'company_name', 'sic_code', 'sic', 'sector', 'industry', 'name']);
114
+ const SIGNAL_FIELDS = new Set(['trend_direction', 'signal', 'flag']);
115
+ function inferColumnType(col, rows) {
116
+ if (rows.length === 0)
117
+ return 'string';
118
+ const sample = rows[0][col];
119
+ if (typeof sample === 'number')
120
+ return Number.isInteger(sample) ? 'int' : 'float';
121
+ return 'string';
122
+ }
123
+ function inferColumnRole(col) {
124
+ if (TIME_FIELDS.has(col))
125
+ return 'time';
126
+ if (GROUP_FIELDS.has(col))
127
+ return 'group';
128
+ if (SIGNAL_FIELDS.has(col))
129
+ return 'signal';
130
+ return 'measure';
131
+ }
132
+ function inferColumnUnit(col) {
133
+ if (col.endsWith('_pct') || col.includes('margin') || col.includes('growth'))
134
+ return 'pct';
135
+ if (col.endsWith('_b') || col.includes('revenue') || col.includes('income')
136
+ || col.includes('profit') || col.includes('expense'))
137
+ return 'USD_B';
138
+ if (col.includes('ratio') || col.includes('multiple'))
139
+ return 'ratio';
140
+ if (col.includes('eps') || col.includes('per_share'))
141
+ return 'USD';
142
+ return null;
143
+ }
144
+ function humanizeColumnName(col) {
145
+ return col
146
+ .replace(/_/g, ' ')
147
+ .replace(/\b\w/g, c => c.toUpperCase())
148
+ .replace('Pct', '%')
149
+ .replace('Yoy', 'YoY')
150
+ .replace('Qoq', 'QoQ');
151
+ }
152
+ // ─── Structured Response Builders ───────────────────────────────────────────
153
+ function buildSearchContext(apiResponse, originalQuery) {
154
+ const results = apiResponse.narrative_results || [];
155
+ const ec = apiResponse.execution_contract || {};
156
+ return {
157
+ query_received: originalQuery,
158
+ query_type: apiResponse.query_type || null,
159
+ scope_confidence: apiResponse.scope_confidence || null,
160
+ tickers_resolved: [...new Set(results.map((r) => r.ticker).filter(Boolean))],
161
+ scope_effective: ec.scope_effective || null,
162
+ mode_effective: ec.mode_effective || null,
163
+ mode_source: ec.mode_source || null,
164
+ scope_source: ec.scope_source || null,
165
+ intent_matched: apiResponse.intent_matched || null,
166
+ results_returned: results.length,
167
+ unique_tickers: new Set(results.map((r) => r.ticker)).size,
168
+ unique_forms: [...new Set(results.map((r) => r.form_type).filter(Boolean))],
169
+ template_match: ec.template_match || null,
170
+ relaxations_applied: ec.relaxations_applied || [],
171
+ execution_id: apiResponse.execution_id || null,
172
+ processing_time_ms: apiResponse.processing_time_ms || null,
173
+ };
174
+ }
175
+ function deriveLayoutHint(apiResponse) {
176
+ const results = apiResponse.narrative_results || [];
177
+ const tickers = new Set(results.map((r) => r.ticker));
178
+ const ec = apiResponse.execution_contract || {};
179
+ const scopeEffective = ec.scope_effective || '';
180
+ const hasMetrics = Array.isArray(apiResponse.xbrl_metrics) && apiResponse.xbrl_metrics.length > 0;
181
+ if (tickers.size >= 2 && hasMetrics) {
182
+ return {
183
+ suggested: 'comparison_dashboard',
184
+ reason: 'multi_ticker_with_metrics',
185
+ panels: ['metrics_chart', 'narrative_side_by_side'],
186
+ tone: 'comparative',
187
+ };
188
+ }
189
+ if (tickers.size >= 2) {
190
+ return {
191
+ suggested: 'comparison_grid',
192
+ reason: 'multi_ticker_narrative',
193
+ panels: ['narrative_by_ticker'],
194
+ tone: 'comparative',
195
+ };
196
+ }
197
+ if (tickers.size === 1 && hasMetrics) {
198
+ return {
199
+ suggested: 'company_profile',
200
+ reason: 'single_ticker_hybrid',
201
+ panels: ['metrics_chart', 'evidence_cards'],
202
+ tone: 'analytical',
203
+ };
204
+ }
205
+ if (scopeEffective === 'MACRO') {
206
+ return {
207
+ suggested: 'cross_company_theme',
208
+ reason: 'macro_scope',
209
+ panels: ['theme_summary', 'evidence_by_ticker'],
210
+ tone: 'analytical',
211
+ };
212
+ }
213
+ if (scopeEffective === 'INDUSTRY') {
214
+ return {
215
+ suggested: 'industry_overview',
216
+ reason: 'industry_scope',
217
+ panels: ['sector_summary', 'evidence_by_ticker'],
218
+ tone: 'analytical',
219
+ };
220
+ }
221
+ return {
222
+ suggested: 'evidence_cards',
223
+ reason: 'single_company_narrative',
224
+ alternatives: ['evidence_timeline', 'risk_factor_summary'],
225
+ tone: 'analytical',
226
+ };
227
+ }
228
+ function deriveMetricsVizHint(rows, schema) {
229
+ const columns = schema?.columns || [];
230
+ if (columns.length === 0)
231
+ return { chart_type: 'table' };
232
+ const timeCol = columns.find((c) => c.role === 'time');
233
+ const measureCols = columns.filter((c) => c.role === 'measure');
234
+ const groupCol = columns.find((c) => c.role === 'group');
235
+ if (!timeCol || measureCols.length === 0)
236
+ return { chart_type: 'table' };
237
+ const tickerCol = groupCol?.name || 'ticker';
238
+ const tickers = new Set(rows.map((r) => r[tickerCol]).filter(Boolean));
239
+ const primaryMeasure = measureCols[0];
240
+ const secondaryMeasure = measureCols.find((c) => c.unit === 'pct' && c.name !== primaryMeasure.name);
241
+ return {
242
+ chart_type: tickers.size > 1 ? 'grouped_bar' : 'line',
243
+ x: { field: timeCol.name, order: 'chronological', label: timeCol.label },
244
+ y_primary: {
245
+ field: primaryMeasure.name,
246
+ label: primaryMeasure.label,
247
+ format: primaryMeasure.unit === 'USD_B' ? 'currency_b'
248
+ : primaryMeasure.unit === 'pct' ? 'pct' : 'number',
249
+ },
250
+ y_secondary: secondaryMeasure ? {
251
+ field: secondaryMeasure.name,
252
+ label: secondaryMeasure.label,
253
+ chart_type: 'bar',
254
+ opacity: 0.3,
255
+ format: 'pct',
256
+ } : undefined,
257
+ group_by: tickers.size > 1 ? tickerCol : undefined,
258
+ color_palette: tickers.size > 1 ? 'comparison_diverging' : 'single_accent',
259
+ formats: {
260
+ currency_b: { prefix: '$', suffix: 'B', decimals: 1 },
261
+ pct: { suffix: '%', decimals: 1, sign: true },
262
+ number: { decimals: 0, comma_separated: true },
263
+ },
264
+ };
265
+ }
266
+ function computeSummaryStats(columnNames, rows) {
267
+ if (rows.length === 0)
268
+ return null;
269
+ const numericCols = columnNames.filter(c => typeof rows[0]?.[c] === 'number'
270
+ && !['fiscal_year', 'year'].includes(c));
271
+ if (numericCols.length === 0)
272
+ return null;
273
+ const primary = numericCols[0];
274
+ const values = rows.map(r => r[primary]).filter((v) => v != null);
275
+ if (values.length === 0)
276
+ return null;
277
+ const min = Math.min(...values);
278
+ const max = Math.max(...values);
279
+ return {
280
+ row_count: rows.length,
281
+ primary_measure: primary,
282
+ min,
283
+ max,
284
+ range_pct: min !== 0
285
+ ? Math.round(((max - min) / Math.abs(min)) * 1000) / 10
286
+ : null,
287
+ };
288
+ }
289
+ function buildColumnMeta(apiColumns, rows) {
290
+ const columnNames = apiColumns.length > 0
291
+ ? apiColumns.map((c) => typeof c === 'string' ? c : c.name)
292
+ : (rows.length > 0 ? Object.keys(rows[0]) : []);
293
+ return columnNames.map((col) => ({
294
+ name: col,
295
+ type: inferColumnType(col, rows),
296
+ role: inferColumnRole(col),
297
+ unit: inferColumnUnit(col),
298
+ label: humanizeColumnName(col),
299
+ }));
300
+ }
301
+ // ─── Text Formatting Helpers (existing behavior) ────────────────────────────
302
+ function formatNumberForTable(val) {
303
+ if (val === null || val === undefined)
304
+ return 'N/A';
305
+ if (typeof val === 'number')
306
+ return Number.isInteger(val) ? String(val) : val.toFixed(2);
307
+ return String(val);
308
+ }
309
+ function buildMarkdownTable(colNames, rows, maxRows = 20) {
310
+ const header = colNames.join(' | ');
311
+ const separator = colNames.map(() => '---').join(' | ');
312
+ const displayRows = rows.slice(0, maxRows);
313
+ const tableRows = displayRows.map((row) => colNames.map((col) => formatNumberForTable(row[col])).join(' | '));
314
+ const table = [header, separator, ...tableRows].join('\n');
315
+ const suffix = rows.length > maxRows
316
+ ? `\n(Showing ${maxRows} of ${rows.length} rows)`
317
+ : '';
318
+ return `${table}${suffix}`;
319
+ }
320
+ // ─── MCP Server ─────────────────────────────────────────────────────────────
63
321
  const server = new McpServer({
64
322
  name: 'etsquare-mcp-sec',
65
- version: '0.2.1',
323
+ version: '0.3.0',
66
324
  });
67
- // Tool 1: Company Lookup
325
+ // ─── Tool 1: Company Lookup ─────────────────────────────────────────────────
68
326
  server.registerTool('etsquare_lookup_company', {
69
327
  title: 'Look Up Company Ticker',
70
328
  description: 'Resolve a company name or partial ticker to its official SEC ticker symbol and CIK number. ' +
@@ -107,7 +365,7 @@ server.registerTool('etsquare_lookup_company', {
107
365
  };
108
366
  }
109
367
  });
110
- // Tool 2: SEC Filing Search
368
+ // ─── Tool 2: SEC Filing Search ──────────────────────────────────────────────
111
369
  server.registerTool('etsquare_search', {
112
370
  title: 'Search SEC Filings',
113
371
  description: 'Search 3.4M+ SEC filing sections (10-K, 10-Q, 8-K) with hybrid retrieval. ' +
@@ -115,9 +373,12 @@ server.registerTool('etsquare_search', {
115
373
  'Execution contract (required):\n' +
116
374
  '- mode_lock: NARRATIVE (recommended) | HYBRID\n' +
117
375
  '- scope_lock: COMPANY (specific tickers) | INDUSTRY (SIC sector) | MACRO (cross-market)\n\n' +
118
- 'For structured financial data (revenue, margins, ratios), use etsquare_discover_metrics + etsquare_execute_metrics instead.\n\n' +
376
+ 'For canonical financial statements (income statement, balance sheet, cash flow), use etsquare_financial_statements.\n' +
377
+ 'For custom financial queries or peer comparisons, use etsquare_discover_metrics + etsquare_execute_metrics.\n\n' +
378
+ 'For INDUSTRY scope, use sector to target a specific industry (e.g., sector="semiconductors"). ' +
119
379
  'Use tickers to scope to specific companies (resolve names first with etsquare_lookup_company). ' +
120
- 'Results include filing text plus safe citation metadata such as chunk_id, accession number, and SEC URL for follow-up retrieval.',
380
+ 'Results include filing text plus safe citation metadata such as chunk_id, accession number, and SEC URL for follow-up retrieval.\n\n' +
381
+ 'Set response_format="structured" for typed JSON with search_context, layout hints, and chart-ready data.',
121
382
  inputSchema: {
122
383
  query: z.string().min(3).describe('What to search for in SEC filings (e.g., "customer concentration risk", "Show NVDA revenue trend")'),
123
384
  mode_lock: z.enum(['NARRATIVE', 'HYBRID']).describe('NARRATIVE for text search, HYBRID for text + any available metrics'),
@@ -125,7 +386,15 @@ server.registerTool('etsquare_search', {
125
386
  top_k: z.number().min(1).max(50).default(5).describe('Number of results to return (default: 5)'),
126
387
  tickers: z.array(z.string()).optional().describe('Limit to specific companies by ticker (e.g., ["AAPL", "MSFT"])'),
127
388
  doc_types: z.array(z.string()).optional().describe('Limit by SEC form type: "10-k", "10-q", "8-k"'),
128
- sic_codes: z.array(z.string()).optional().describe('Limit by SIC industry code (e.g., "7372", "3674")'),
389
+ sector: z.string().optional().describe('Industry/sector hint for INDUSTRY-scope queries. Resolved internally to SIC codes. ' +
390
+ 'Examples: "software", "fintech", "energy", "semiconductors", "biotech", "REITs", "banks", ' +
391
+ '"defense", "utilities", "oil and gas", "healthcare", "cloud", "cybersecurity". ' +
392
+ 'If sic_codes is also provided, sic_codes takes precedence.'),
393
+ sic_codes: z.array(z.string()).optional().describe('Expert override: limit by SIC industry code (e.g., "7372", "3674"). Takes precedence over sector.'),
394
+ response_format: z.enum(['text', 'structured']).default('text')
395
+ .describe('"text" returns numbered citations (default). "structured" returns JSON with search_context, typed results, and layout hints.'),
396
+ include_context: z.boolean().default(false)
397
+ .describe('Auto-expand top 2 truncated results with surrounding context'),
129
398
  },
130
399
  }, async (input) => {
131
400
  if (containsGuardrailBypass(input))
@@ -136,6 +405,7 @@ server.registerTool('etsquare_search', {
136
405
  mode_lock: input.mode_lock,
137
406
  scope_lock: input.scope_lock,
138
407
  top_k: input.top_k,
408
+ response_format: input.response_format,
139
409
  });
140
410
  const result = await client.search(input);
141
411
  const searchResults = Array.isArray(result.narrative_results)
@@ -158,7 +428,67 @@ server.registerTool('etsquare_search', {
158
428
  text += '\nTry broadening your query or removing filters.';
159
429
  return { content: [{ type: 'text', text }] };
160
430
  }
161
- // Format narrative citations
431
+ // Auto-expand truncated chunks with surrounding context
432
+ if (input.include_context) {
433
+ const truncatedChunks = searchResults
434
+ .filter((r) => r.chunk_text_truncated)
435
+ .slice(0, 2);
436
+ for (const chunk of truncatedChunks) {
437
+ try {
438
+ const ctx = await client.getChunkContext({
439
+ chunk_id: chunk.chunk_id,
440
+ neighbor_span: 1,
441
+ window: 1200,
442
+ });
443
+ const ctxData = ctx.context || {};
444
+ chunk._expanded_context = {
445
+ before: ctxData.before || null,
446
+ highlight: ctxData.highlight || null,
447
+ after: ctxData.after || null,
448
+ };
449
+ }
450
+ catch {
451
+ // Non-fatal — skip context expansion on failure
452
+ }
453
+ }
454
+ }
455
+ // ── Structured response path ──
456
+ if (input.response_format === 'structured') {
457
+ const metricsColumnMeta = xbrlMetrics.length > 0
458
+ ? buildColumnMeta(result.xbrl_output_schema?.columns || [], xbrlMetrics)
459
+ : null;
460
+ const structured = {
461
+ search_context: buildSearchContext(result, input.query),
462
+ results: searchResults
463
+ .slice(0, input.top_k || 5)
464
+ .map((r, i) => ({
465
+ index: i + 1,
466
+ chunk_id: r.chunk_id,
467
+ ticker: r.ticker,
468
+ company_name: r.company_name,
469
+ form_type: r.form_type,
470
+ filing_date: r.filing_date,
471
+ item_code: r.item_code,
472
+ section_label: mapItemCodeToLabel(r.item_code),
473
+ score: r.similarity_score,
474
+ chunk_text: r.chunk_text,
475
+ truncated: r.chunk_text_truncated || false,
476
+ chunk_text_length: r.chunk_text_length || null,
477
+ accession_number: r.accession_number,
478
+ sec_url: r.sec_url,
479
+ expanded_context: r._expanded_context || undefined,
480
+ })),
481
+ metrics: xbrlMetrics.length > 0 ? {
482
+ rows: xbrlMetrics,
483
+ columns: metricsColumnMeta,
484
+ visualization_hint: deriveMetricsVizHint(xbrlMetrics, { columns: metricsColumnMeta }),
485
+ } : null,
486
+ layout_hint: deriveLayoutHint(result),
487
+ };
488
+ log('info', `Search returned ${resultCount} citations, ${xbrlMetrics.length} metrics [structured]`);
489
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
490
+ }
491
+ // ── Text response path (existing behavior) ──
162
492
  const sections = [];
163
493
  if (resultCount > 0) {
164
494
  const formatted = searchResults
@@ -187,6 +517,15 @@ server.registerTool('etsquare_search', {
187
517
  const snippet = r.chunk_text.substring(0, 600);
188
518
  lines.push(` ${snippet}${r.chunk_text.length > 600 ? '...' : ''}`);
189
519
  }
520
+ // Include expanded context if available
521
+ if (r._expanded_context) {
522
+ if (r._expanded_context.before) {
523
+ lines.push(` [Context before]: ${r._expanded_context.before.substring(0, 300)}...`);
524
+ }
525
+ if (r._expanded_context.after) {
526
+ lines.push(` [Context after]: ${r._expanded_context.after.substring(0, 300)}...`);
527
+ }
528
+ }
190
529
  const url = r.sec_url || r.source_url;
191
530
  if (url) {
192
531
  lines.push(` SEC URL: ${url}`);
@@ -202,25 +541,10 @@ server.registerTool('etsquare_search', {
202
541
  const header = headerParts.join('\n');
203
542
  sections.push(`${header}\n\n${formatted}`);
204
543
  }
205
- // Format XBRL metrics if present (handles both wide-format and long-format rows)
206
544
  if (xbrlMetrics.length > 0) {
207
545
  const colNames = Object.keys(xbrlMetrics[0] || {}).filter((k) => !k.startsWith('_'));
208
- const header = colNames.join(' | ');
209
- const separator = colNames.map(() => '---').join(' | ');
210
- const displayRows = xbrlMetrics.slice(0, 20);
211
- const tableRows = displayRows.map((row) => colNames.map((col) => {
212
- const val = row[col];
213
- if (val === null || val === undefined)
214
- return 'N/A';
215
- if (typeof val === 'number')
216
- return Number.isInteger(val) ? String(val) : val.toFixed(2);
217
- return String(val);
218
- }).join(' | '));
219
- const table = [header, separator, ...tableRows].join('\n');
220
- const suffix = xbrlMetrics.length > 20
221
- ? `\n(Showing 20 of ${xbrlMetrics.length} rows)`
222
- : '';
223
- sections.push(`XBRL Metrics (${xbrlMetrics.length}):\n\n${table}${suffix}`);
546
+ const table = buildMarkdownTable(colNames, xbrlMetrics);
547
+ sections.push(`XBRL Metrics (${xbrlMetrics.length}):\n\n${table}`);
224
548
  }
225
549
  log('info', `Search returned ${resultCount} citations, ${xbrlMetrics.length} metrics`);
226
550
  return {
@@ -238,28 +562,448 @@ server.registerTool('etsquare_search', {
238
562
  };
239
563
  }
240
564
  });
241
- // Tool 3: Execute Metrics (XBRL structured data)
565
+ // ─── Tool 3: Execute Metrics ────────────────────────────────────────────────
566
+ server.registerTool('etsquare_financial_statements', {
567
+ title: 'Get Financial Statements',
568
+ description: 'Get canonical income statement, balance sheet, or cash flow statement for a company. ' +
569
+ 'Assembles structured line items from XBRL facts with concept precedence and provenance.\n\n' +
570
+ 'Each line item includes the selected XBRL concept, priority rank, and unit for auditability.\n\n' +
571
+ 'Coverage: ~5,100 tickers for income statement and cash flow. ' +
572
+ 'Balance sheet coverage is limited for some companies pending XBRL pipeline enhancement.\n\n' +
573
+ 'For custom financial queries or peer comparisons, use etsquare_discover_metrics + etsquare_execute_metrics instead.',
574
+ inputSchema: {
575
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
576
+ statement_type: z.enum(['income_statement', 'balance_sheet', 'cash_flow'])
577
+ .describe('Which financial statement to return'),
578
+ period_mode: z.enum(['latest_annual', 'latest_quarterly', 'last_n_annual', 'last_n_quarterly'])
579
+ .default('last_n_annual')
580
+ .describe('Period selection: latest single period or last N periods'),
581
+ n_periods: z.number().min(1).max(20).default(5)
582
+ .describe('Number of periods for last_n modes (default: 5)'),
583
+ },
584
+ }, async (input) => {
585
+ if (containsGuardrailBypass(input))
586
+ return guardrailViolationResponse();
587
+ try {
588
+ const result = await client.getFinancialStatement(input);
589
+ const periods = Array.isArray(result.periods) ? result.periods : [];
590
+ const lineOrder = Array.isArray(result.line_order) ? result.line_order : [];
591
+ const ticker = result.ticker || input.ticker.toUpperCase();
592
+ if (periods.length === 0) {
593
+ return {
594
+ content: [{
595
+ type: 'text',
596
+ text: `No ${input.statement_type.replace(/_/g, ' ')} data found for ${ticker}. ` +
597
+ `The company may not have XBRL data available for the requested period.`,
598
+ }],
599
+ };
600
+ }
601
+ // Format as markdown table
602
+ const labels = lineOrder.filter(label => {
603
+ return periods.some((p) => p.lines?.[label]?.value != null);
604
+ });
605
+ // Build header row
606
+ const periodHeaders = periods.map((p) => `FY${p.fiscal_year}${p.fiscal_period !== 'FY' ? ' ' + p.fiscal_period : ''}`);
607
+ const formatValue = (val, label) => {
608
+ if (val == null)
609
+ return '—';
610
+ if (label.startsWith('eps_'))
611
+ return val.toFixed(2);
612
+ if (label.startsWith('shares_'))
613
+ return (val / 1e6).toFixed(0) + 'M';
614
+ if (Math.abs(val) >= 1e9)
615
+ return (val / 1e9).toFixed(2) + 'B';
616
+ if (Math.abs(val) >= 1e6)
617
+ return (val / 1e6).toFixed(1) + 'M';
618
+ return val.toLocaleString();
619
+ };
620
+ const labelDisplay = (label) => label.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
621
+ let table = `## ${ticker} — ${input.statement_type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}\n\n`;
622
+ table += `| Line Item | ${periodHeaders.join(' | ')} |\n`;
623
+ table += `|---|${periodHeaders.map(() => '---:').join('|')}|\n`;
624
+ for (const label of labels) {
625
+ const values = periods.map((p) => formatValue(p.lines?.[label]?.value, label));
626
+ table += `| ${labelDisplay(label)} | ${values.join(' | ')} |\n`;
627
+ }
628
+ // Add derived metrics (e.g., FCF)
629
+ const derivedKeys = new Set();
630
+ for (const p of periods) {
631
+ if (p.derived) {
632
+ for (const k of Object.keys(p.derived))
633
+ derivedKeys.add(k);
634
+ }
635
+ }
636
+ if (derivedKeys.size > 0) {
637
+ table += `|---|${periodHeaders.map(() => '---:').join('|')}|\n`;
638
+ for (const dk of derivedKeys) {
639
+ const values = periods.map((p) => formatValue(p.derived?.[dk]?.value, dk));
640
+ table += `| **${labelDisplay(dk)}** | ${values.join(' | ')} |\n`;
641
+ }
642
+ }
643
+ // Provenance note
644
+ const firstPeriod = periods[0];
645
+ const sampleLine = firstPeriod?.lines?.[labels[0]];
646
+ if (sampleLine) {
647
+ table += `\n*Source: SEC XBRL filings. Concept precedence via VT_CF_XBRL_CONCEPT_FAMILIES.*`;
648
+ }
649
+ return {
650
+ content: [{ type: 'text', text: table }],
651
+ };
652
+ }
653
+ catch (error) {
654
+ const message = error instanceof Error ? error.message : 'Unknown error';
655
+ return {
656
+ content: [{ type: 'text', text: `Financial statement request failed: ${message}` }],
657
+ isError: true,
658
+ };
659
+ }
660
+ });
661
+ server.registerTool('etsquare_insider_trades', {
662
+ title: 'Insider Trading Activity',
663
+ description: 'Get insider buying and selling activity for a company from SEC Form 3/4/5 filings. ' +
664
+ 'Shows officer/director stock purchases, sales, option exercises, and tax withholdings.\n\n' +
665
+ 'Data is sourced directly from SEC EDGAR ownership filings, filed within 2 business days of each transaction.\n\n' +
666
+ 'Summary includes open-market buy/sell counts, net shares, and total values. ' +
667
+ 'Derivative transactions (options, RSUs) are separated from open-market trades.',
668
+ inputSchema: {
669
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
670
+ days_back: z.number().min(1).max(730).default(90)
671
+ .describe('Days of history to return (default: 90)'),
672
+ transaction_types: z.array(z.string()).optional()
673
+ .describe('Filter by type: "P" (purchase), "S" (sale), "M" (exercise), "F" (tax withholding), "A" (award)'),
674
+ include_derivatives: z.boolean().default(true)
675
+ .describe('Include derivative transactions like options and RSUs (default: true)'),
676
+ },
677
+ }, async (input) => {
678
+ if (containsGuardrailBypass(input))
679
+ return guardrailViolationResponse();
680
+ try {
681
+ const result = await client.getInsiderTransactions(input);
682
+ const transactions = Array.isArray(result.transactions) ? result.transactions : [];
683
+ const summary = result.summary || {};
684
+ const ticker = result.ticker || input.ticker.toUpperCase();
685
+ if (transactions.length === 0) {
686
+ return {
687
+ content: [{
688
+ type: 'text',
689
+ text: `No insider transactions found for ${ticker} in the last ${input.days_back || 90} days.`,
690
+ }],
691
+ };
692
+ }
693
+ // Format summary
694
+ const fmtVal = (v) => {
695
+ if (v == null)
696
+ return '$0';
697
+ if (Math.abs(v) >= 1e9)
698
+ return `$${(v / 1e9).toFixed(1)}B`;
699
+ if (Math.abs(v) >= 1e6)
700
+ return `$${(v / 1e6).toFixed(1)}M`;
701
+ if (Math.abs(v) >= 1e3)
702
+ return `$${(v / 1e3).toFixed(0)}K`;
703
+ return `$${v.toFixed(0)}`;
704
+ };
705
+ let text = `## ${ticker} — Insider Trading (Last ${input.days_back || 90} Days)\n\n`;
706
+ // Summary line
707
+ const netLabel = (summary.net_shares_open_market ?? 0) >= 0 ? 'net buying' : 'net selling';
708
+ text += `**Open market**: ${summary.open_market_buys || 0} buys (${fmtVal(summary.total_buy_value)}) / `;
709
+ text += `${summary.open_market_sells || 0} sells (${fmtVal(summary.total_sell_value)})`;
710
+ if (summary.exercises)
711
+ text += ` | ${summary.exercises} exercises`;
712
+ if (summary.tax_withholdings)
713
+ text += ` | ${summary.tax_withholdings} tax withholdings`;
714
+ text += `\n\n`;
715
+ // Transaction table — show top 20
716
+ const shown = transactions.slice(0, 20);
717
+ text += `| Date | Insider | Title | Type | Shares | Price | Value |\n`;
718
+ text += `|------|---------|-------|------|-------:|------:|------:|\n`;
719
+ for (const t of shown) {
720
+ const shares = t.shares != null ? t.shares.toLocaleString() : '—';
721
+ const price = t.price_per_share != null ? `$${t.price_per_share.toFixed(2)}` : '—';
722
+ const value = t.total_value != null ? fmtVal(t.total_value) : '—';
723
+ const typeLabel = t.is_derivative ? `${t.transaction_type} *` : t.transaction_type;
724
+ text += `| ${t.transaction_date} | ${t.owner_name} | ${t.officer_title || '—'} | ${typeLabel} | ${shares} | ${price} | ${value} |\n`;
725
+ }
726
+ if (transactions.length > 20) {
727
+ text += `\n*Showing 20 of ${transactions.length} transactions.*\n`;
728
+ }
729
+ text += `\n*\\* = derivative transaction (options/RSUs). Source: SEC Form 4 filings via EDGAR.*`;
730
+ return {
731
+ content: [{ type: 'text', text }],
732
+ };
733
+ }
734
+ catch (error) {
735
+ const message = error instanceof Error ? error.message : 'Unknown error';
736
+ return {
737
+ content: [{ type: 'text', text: `Insider trades request failed: ${message}` }],
738
+ isError: true,
739
+ };
740
+ }
741
+ });
742
+ server.registerTool('etsquare_institutional_holdings', {
743
+ title: 'Institutional Ownership (13F)',
744
+ description: 'Get institutional investor holdings for a company from SEC 13F filings. ' +
745
+ 'Shows tracked managers holding the stock, their position sizes, and portfolio concentration.\n\n' +
746
+ 'Data is from tracked institutional managers (top filers by AUM). ' +
747
+ 'Coverage is partial — not all institutional holders are tracked. ' +
748
+ 'Multiple rows per manager may appear due to sub-manager reporting. ' +
749
+ 'Source: SEC EDGAR 13F-HR filings, filed quarterly.',
750
+ inputSchema: {
751
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "NVDA", "AAPL")'),
752
+ quarters: z.number().min(1).max(8).default(2)
753
+ .describe('Quarters of history (default: 2)'),
754
+ },
755
+ }, async (input) => {
756
+ if (containsGuardrailBypass(input))
757
+ return guardrailViolationResponse();
758
+ try {
759
+ const result = await client.getInstitutionalHoldings(input);
760
+ const summary = result.summary || {};
761
+ const holdersByQuarter = result.holders_by_quarter || {};
762
+ const ticker = result.ticker || input.ticker.toUpperCase();
763
+ const quarters = Object.keys(holdersByQuarter).sort().reverse();
764
+ if (quarters.length === 0) {
765
+ return {
766
+ content: [{
767
+ type: 'text',
768
+ text: `No institutional holdings found for ${ticker} in tracked managers. ` +
769
+ `The stock may not be held by the top institutional filers currently tracked.`,
770
+ }],
771
+ };
772
+ }
773
+ const fmtVal = (v) => {
774
+ if (v == null)
775
+ return '—';
776
+ if (Math.abs(v) >= 1e12)
777
+ return `$${(v / 1e12).toFixed(1)}T`;
778
+ if (Math.abs(v) >= 1e9)
779
+ return `$${(v / 1e9).toFixed(1)}B`;
780
+ if (Math.abs(v) >= 1e6)
781
+ return `$${(v / 1e6).toFixed(1)}M`;
782
+ if (Math.abs(v) >= 1e3)
783
+ return `$${(v / 1e3).toFixed(0)}K`;
784
+ return `$${v.toFixed(0)}`;
785
+ };
786
+ let text = `## ${ticker} — Institutional Holders (13F)\n\n`;
787
+ text += `**Distinct tracked managers**: ${summary.distinct_tracked_managers || 0} | `;
788
+ text += `**Holder rows**: ${summary.holder_rows || 0} | `;
789
+ text += `**Tracked shares**: ${(summary.tracked_shares_held || 0).toLocaleString()} | `;
790
+ text += `**Tracked value**: ${fmtVal(summary.tracked_value_usd)}\n`;
791
+ text += `*${summary.coverage_note || 'Partial coverage — tracked managers only'}*\n\n`;
792
+ for (const q of quarters) {
793
+ const holders = holdersByQuarter[q] || [];
794
+ if (holders.length === 0)
795
+ continue;
796
+ text += `### ${q}\n\n`;
797
+ text += `| Manager | Shares | Value | Portfolio % | Discretion |\n`;
798
+ text += `|---------|-------:|------:|------------:|:-----------|\n`;
799
+ for (const h of holders.slice(0, 20)) {
800
+ const shares = h.shares != null ? h.shares.toLocaleString() : '—';
801
+ const value = fmtVal(h.value_usd);
802
+ const pct = h.portfolio_pct != null ? `${h.portfolio_pct.toFixed(2)}%` : '—';
803
+ const disc = h.investment_discretion || '—';
804
+ const label = h.manager_label || h.manager_name || '—';
805
+ text += `| ${label} | ${shares} | ${value} | ${pct} | ${disc} |\n`;
806
+ }
807
+ if (holders.length > 20) {
808
+ text += `\n*Showing 20 of ${holders.length} tracked holders.*\n`;
809
+ }
810
+ text += '\n';
811
+ }
812
+ text += `*Source: SEC 13F-HR filings via EDGAR. Tracked managers only — not full institutional ownership.*`;
813
+ return {
814
+ content: [{ type: 'text', text }],
815
+ };
816
+ }
817
+ catch (error) {
818
+ const message = error instanceof Error ? error.message : 'Unknown error';
819
+ return {
820
+ content: [{ type: 'text', text: `Institutional holdings request failed: ${message}` }],
821
+ isError: true,
822
+ };
823
+ }
824
+ });
825
+ server.registerTool('etsquare_earnings_actuals', {
826
+ title: 'Earnings Actuals & Guidance',
827
+ description: 'Get issuer-reported earnings actuals and forward guidance for a company from SEC 8-K filings. ' +
828
+ 'Extracts revenue, EPS (basic/diluted), and net income from Item 2.02 press releases.\n\n' +
829
+ 'Values are deterministically extracted from exhibit press releases (EX-99.1) attached to 8-K filings. ' +
830
+ 'Only explicit issuer-reported values are included — no analyst estimates or consensus.\n\n' +
831
+ 'Guidance shows forward-looking ranges (low/high) when the issuer provides them.\n\n' +
832
+ 'Coverage: ~1,900 tickers, most recent 1-2 quarters (Dec 2025 onward). ' +
833
+ 'Historical depth is expanding — some tickers may have fewer quarters than requested.\n\n' +
834
+ 'Source: SEC EDGAR 8-K Item 2.02 filings with attached press releases.',
835
+ inputSchema: {
836
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
837
+ quarters: z.number().min(1).max(20).default(4)
838
+ .describe('Quarters of history (default: 4)'),
839
+ metrics: z.array(z.string()).optional()
840
+ .describe('Filter metrics: "revenue", "diluted_eps", "basic_eps", "net_income"'),
841
+ },
842
+ }, async (input) => {
843
+ if (containsGuardrailBypass(input))
844
+ return guardrailViolationResponse();
845
+ try {
846
+ const result = await client.getEarningsActuals(input);
847
+ const summary = result.summary || {};
848
+ const actuals = result.actuals || [];
849
+ const guidance = result.guidance || [];
850
+ const ticker = result.ticker || input.ticker.toUpperCase();
851
+ if (actuals.length === 0 && guidance.length === 0) {
852
+ return {
853
+ content: [{
854
+ type: 'text',
855
+ text: `No earnings actuals found for ${ticker}. ` +
856
+ `Earnings data is extracted from 8-K Item 2.02 press releases — ` +
857
+ `the company may not have earnings filings in the extracted dataset yet.`,
858
+ }],
859
+ };
860
+ }
861
+ const fmtVal = (v, unit) => {
862
+ if (v == null)
863
+ return '—';
864
+ if (unit === 'USD_PER_SHARE')
865
+ return `$${v.toFixed(2)}`;
866
+ if (Math.abs(v) >= 1e12)
867
+ return `$${(v / 1e12).toFixed(1)}T`;
868
+ if (Math.abs(v) >= 1e9)
869
+ return `$${(v / 1e9).toFixed(2)}B`;
870
+ if (Math.abs(v) >= 1e6)
871
+ return `$${(v / 1e6).toFixed(1)}M`;
872
+ if (Math.abs(v) >= 1e3)
873
+ return `$${(v / 1e3).toFixed(0)}K`;
874
+ return `$${v.toFixed(0)}`;
875
+ };
876
+ let text = `## ${ticker} — Earnings Actuals\n\n`;
877
+ text += `**Metrics found**: ${summary.total_actuals || 0} actuals, ${summary.total_guidance || 0} guidance\n`;
878
+ if (summary.date_range?.earliest && summary.date_range?.latest) {
879
+ text += `**Period**: ${summary.date_range.earliest} to ${summary.date_range.latest}\n`;
880
+ }
881
+ text += '\n';
882
+ if (actuals.length > 0) {
883
+ text += `| Filing Date | Metric | Value | GAAP | FY | FQ | Confidence |\n`;
884
+ text += `|-------------|--------|------:|:----:|---:|---:|-----------:|\n`;
885
+ for (const a of actuals) {
886
+ const val = fmtVal(a.value, a.unit);
887
+ const gaap = a.gaap_flag || '—';
888
+ const fy = a.fiscal_year ?? '—';
889
+ const fq = a.fiscal_quarter ?? '—';
890
+ const conf = a.confidence != null ? `${(a.confidence * 100).toFixed(0)}%` : '—';
891
+ text += `| ${a.filing_date || '—'} | ${a.metric_name} | ${val} | ${gaap} | ${fy} | ${fq} | ${conf} |\n`;
892
+ }
893
+ text += '\n';
894
+ }
895
+ if (guidance.length > 0) {
896
+ text += `### Forward Guidance\n\n`;
897
+ text += `| Filing Date | Metric | Low | High | Midpoint | GAAP | Target FY | Target FQ |\n`;
898
+ text += `|-------------|--------|----:|-----:|---------:|:----:|----------:|----------:|\n`;
899
+ for (const g of guidance) {
900
+ const low = fmtVal(g.low_value, g.unit);
901
+ const high = fmtVal(g.high_value, g.unit);
902
+ const mid = g.midpoint != null ? fmtVal(g.midpoint, g.unit) : '—';
903
+ const gaap = g.gaap_flag || '—';
904
+ const fy = g.fiscal_year ?? '—';
905
+ const fq = g.fiscal_quarter ?? '—';
906
+ text += `| ${g.filing_date || '—'} | ${g.metric_name} | ${low} | ${high} | ${mid} | ${gaap} | ${fy} | ${fq} |\n`;
907
+ }
908
+ text += '\n';
909
+ }
910
+ text += `*Source: SEC 8-K Item 2.02 press releases — deterministic extraction from issuer filings.*`;
911
+ return {
912
+ content: [{ type: 'text', text }],
913
+ };
914
+ }
915
+ catch (error) {
916
+ const message = error instanceof Error ? error.message : 'Unknown error';
917
+ return {
918
+ content: [{ type: 'text', text: `Earnings actuals request failed: ${message}` }],
919
+ isError: true,
920
+ };
921
+ }
922
+ });
242
923
  server.registerTool('etsquare_execute_metrics', {
243
924
  title: 'Execute Financial Metrics Query',
244
925
  description: 'Execute an XBRL metrics template by template_id to get structured financial data ' +
245
926
  '(numbers, ratios, time series). Returns tabular rows with columns like revenue, margins, EPS, etc.\n\n' +
246
- 'Common bind_params: p_ticker (company ticker), p_sic_code (industry), ' +
247
- 'p_start_year / p_end_year (time range, e.g., 2023-2025).',
927
+ 'Common bind_params: p_tickers (comma-separated tickers), p_ticker (single ticker), p_sic_code (industry), ' +
928
+ 'p_start_year / p_end_year (time range, e.g., 2023-2025).\n\n' +
929
+ 'Use the tickers array to pass multiple companies — automatically joins as p_tickers comma-separated string.\n\n' +
930
+ 'Set response_format="structured" for typed JSON with column metadata, visualization hints, and summary stats.',
248
931
  inputSchema: {
249
932
  template_id: z.string().min(10).describe('Template ID for metrics execution'),
250
933
  bind_params: z.record(z.unknown()).optional().describe('Template parameters as key-value pairs'),
251
934
  row_limit: z.number().min(1).max(500).optional().describe('Max rows to return (default: 100)'),
935
+ tickers: z.array(z.string()).max(5).optional()
936
+ .describe('Execute same template for multiple tickers and merge results. Overrides p_ticker in bind_params.'),
937
+ response_format: z.enum(['text', 'structured']).default('text')
938
+ .describe('"text" returns markdown table (default). "structured" returns JSON with column metadata and viz hints.'),
252
939
  },
253
940
  }, async (input) => {
254
941
  if (containsGuardrailBypass(input))
255
942
  return guardrailViolationResponse();
256
943
  try {
257
- log('debug', 'Executing metrics template', { template_id: input.template_id });
258
- const result = await client.executeMetrics(input);
259
- const rows = result.rows || [];
260
- const rowCount = result.row_count || rows.length;
261
- const truncated = result.truncated || false;
262
- const columns = result.columns || [];
944
+ log('debug', 'Executing metrics template', {
945
+ template_id: input.template_id,
946
+ tickers: input.tickers,
947
+ });
948
+ let rows = [];
949
+ let columns = [];
950
+ let rowCount = 0;
951
+ let truncated = false;
952
+ let vizHintFromApi = null;
953
+ // Multi-ticker: join as comma-separated p_tickers (single call)
954
+ // Templates use p_tickers (plural, comma-separated) for multi-company queries.
955
+ // Fallback: if p_tickers fails, try N parallel calls with p_ticker (singular).
956
+ if (input.tickers && input.tickers.length > 0) {
957
+ const joinedTickers = input.tickers.join(',');
958
+ const baseParams = { ...(input.bind_params || {}) };
959
+ // Remove any existing p_ticker — p_tickers takes precedence
960
+ delete baseParams.p_ticker;
961
+ try {
962
+ // Primary: single call with p_tickers (comma-separated)
963
+ const result = await client.executeMetrics({
964
+ template_id: input.template_id,
965
+ bind_params: { ...baseParams, p_tickers: joinedTickers },
966
+ row_limit: input.row_limit,
967
+ });
968
+ rows = result.rows || [];
969
+ rowCount = result.row_count || rows.length;
970
+ truncated = result.truncated || false;
971
+ columns = result.columns || [];
972
+ vizHintFromApi = result.visualization_hint || null;
973
+ }
974
+ catch {
975
+ // Fallback: parallel calls with p_ticker (singular) per ticker
976
+ log('debug', 'p_tickers failed, falling back to parallel p_ticker calls');
977
+ const results = await Promise.allSettled(input.tickers.map(ticker => client.executeMetrics({
978
+ template_id: input.template_id,
979
+ bind_params: { ...baseParams, p_ticker: ticker },
980
+ row_limit: input.row_limit,
981
+ })));
982
+ for (const res of results) {
983
+ if (res.status === 'fulfilled' && res.value) {
984
+ const val = res.value;
985
+ if (Array.isArray(val.rows))
986
+ rows.push(...val.rows);
987
+ if (!columns.length && Array.isArray(val.columns))
988
+ columns = val.columns;
989
+ if (val.truncated)
990
+ truncated = true;
991
+ if (!vizHintFromApi)
992
+ vizHintFromApi = val.visualization_hint || null;
993
+ }
994
+ }
995
+ rowCount = rows.length;
996
+ }
997
+ }
998
+ else {
999
+ // Single execution
1000
+ const result = await client.executeMetrics(input);
1001
+ rows = result.rows || [];
1002
+ rowCount = result.row_count || rows.length;
1003
+ truncated = result.truncated || false;
1004
+ columns = result.columns || [];
1005
+ vizHintFromApi = result.visualization_hint || null;
1006
+ }
263
1007
  if (rowCount === 0) {
264
1008
  return {
265
1009
  content: [{
@@ -268,21 +1012,31 @@ server.registerTool('etsquare_execute_metrics', {
268
1012
  }],
269
1013
  };
270
1014
  }
271
- const colNames = columns.length > 0 ? columns.map((c) => c.name) : Object.keys(rows[0] || {});
272
- const header = colNames.join(' | ');
273
- const separator = colNames.map(() => '---').join(' | ');
274
- const displayRows = rows.slice(0, 20);
275
- const tableRows = displayRows.map((row) => colNames.map((col) => {
276
- const val = row[col];
277
- if (val === null || val === undefined)
278
- return 'N/A';
279
- if (typeof val === 'number')
280
- return Number.isInteger(val) ? String(val) : val.toFixed(2);
281
- return String(val);
282
- }).join(' | '));
283
- const table = [header, separator, ...tableRows].join('\n');
1015
+ // ── Structured response path ──
1016
+ if (input.response_format === 'structured') {
1017
+ const columnMeta = buildColumnMeta(columns, rows);
1018
+ // API-returned hint takes precedence over auto-derived
1019
+ const autoHint = deriveMetricsVizHint(rows, { columns: columnMeta });
1020
+ const colNames = columnMeta.map((c) => c.name);
1021
+ const structured = {
1022
+ template_id: input.template_id,
1023
+ columns: columnMeta,
1024
+ rows,
1025
+ row_count: rowCount,
1026
+ truncated,
1027
+ visualization_hint: vizHintFromApi || autoHint,
1028
+ summary_stats: computeSummaryStats(colNames, rows),
1029
+ };
1030
+ log('info', `Metrics query returned ${rowCount} rows [structured]`);
1031
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
1032
+ }
1033
+ // ── Text response path (existing behavior) ──
1034
+ const colNames = columns.length > 0
1035
+ ? columns.map((c) => c.name)
1036
+ : Object.keys(rows[0] || {});
1037
+ const table = buildMarkdownTable(colNames, rows);
284
1038
  const suffix = truncated || rows.length > 20
285
- ? `\n\n(Showing ${displayRows.length} of ${rowCount} rows${truncated ? ', results truncated' : ''})`
1039
+ ? `\n\n(Showing ${Math.min(rows.length, 20)} of ${rowCount} rows${truncated ? ', results truncated' : ''})`
286
1040
  : '';
287
1041
  log('info', `Metrics query returned ${rowCount} rows`);
288
1042
  return {
@@ -300,7 +1054,7 @@ server.registerTool('etsquare_execute_metrics', {
300
1054
  };
301
1055
  }
302
1056
  });
303
- // Tool 4: Discover Metrics Templates
1057
+ // ─── Tool 4: Discover Metrics Templates ─────────────────────────────────────
304
1058
  server.registerTool('etsquare_discover_metrics', {
305
1059
  title: 'Discover Financial Metrics Templates',
306
1060
  description: 'Find available XBRL metrics templates by business question. ' +
@@ -309,12 +1063,15 @@ server.registerTool('etsquare_discover_metrics', {
309
1063
  'Do not use this for narrative KPI questions like same-store sales, comparable sales, traffic, guest count, average check, or AUV. ' +
310
1064
  'For those, use etsquare_search in NARRATIVE mode instead.\n\n' +
311
1065
  'Example: "revenue trend by quarter" → returns matching templates with their required bind_params.\n\n' +
312
- 'Workflow: discover_metrics → pick template_id → execute_metrics with bind_params.',
1066
+ 'Workflow: discover_metrics → pick template_id → execute_metrics with bind_params.\n\n' +
1067
+ 'Set response_format="structured" for JSON with similarity scores and visualization hints.',
313
1068
  inputSchema: {
314
1069
  question: z.string().min(3).describe('Structured metrics question (e.g., "revenue trend by quarter", "gross margin trend", "EPS trend")'),
315
1070
  scenario: z.enum(['snapshot', 'trends', 'peer_benchmark']).optional().describe('Filter by scenario type'),
316
1071
  metric_family: z.string().optional().describe('Filter by metric family: REVENUE, EARNINGS, PROFITABILITY_MARGIN, LEVERAGE_DEBT, FREE_CASH_FLOW, LIQUIDITY'),
317
1072
  max_results: z.number().min(1).max(10).default(5).optional().describe('Max templates to return'),
1073
+ response_format: z.enum(['text', 'structured']).default('text')
1074
+ .describe('"text" returns formatted list (default). "structured" returns JSON with scores and viz hints.'),
318
1075
  },
319
1076
  }, async (input) => {
320
1077
  if (containsGuardrailBypass(input))
@@ -342,13 +1099,32 @@ server.registerTool('etsquare_discover_metrics', {
342
1099
  if (examples.length > 0) {
343
1100
  text += `\nValid metrics examples: ${examples.join('; ')}`;
344
1101
  }
345
- return {
346
- content: [{
347
- type: 'text',
348
- text,
349
- }],
1102
+ return { content: [{ type: 'text', text }] };
1103
+ }
1104
+ // ── Structured response path ──
1105
+ if (input.response_format === 'structured') {
1106
+ const structuredTemplates = templates.map((t, i) => ({
1107
+ template_id: t.template_id,
1108
+ name: t.title,
1109
+ description: t.description,
1110
+ recommended: i === 0,
1111
+ similarity_score: t.similarity_score || null,
1112
+ confidence_score: t.confidence_score || null,
1113
+ scenario: t.scenario,
1114
+ category: t.category,
1115
+ required_params: t.required_params || {},
1116
+ columns: t.columns || [],
1117
+ visualization_hint: t.visualization_hint || null,
1118
+ }));
1119
+ const structured = {
1120
+ question: result.question || input.question,
1121
+ templates: structuredTemplates,
1122
+ guidance: guidance || null,
350
1123
  };
1124
+ log('info', `Discovered ${templates.length} templates [structured]`);
1125
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
351
1126
  }
1127
+ // ── Text response path (existing behavior) ──
352
1128
  const formatted = templates
353
1129
  .map((t, i) => {
354
1130
  const lines = [
@@ -395,7 +1171,7 @@ server.registerTool('etsquare_discover_metrics', {
395
1171
  };
396
1172
  }
397
1173
  });
398
- // Tool 5: Expand a single chunk to full text
1174
+ // ─── Tool 5: Get Full Chunk Text ────────────────────────────────────────────
399
1175
  server.registerTool('etsquare_get_chunk', {
400
1176
  title: 'Get Full Chunk Text',
401
1177
  description: 'Fetch the full text for a specific search result chunk. ' +
@@ -430,7 +1206,7 @@ server.registerTool('etsquare_get_chunk', {
430
1206
  };
431
1207
  }
432
1208
  });
433
- // Tool 6: Expand chunk with surrounding context
1209
+ // ─── Tool 6: Get Chunk Context ──────────────────────────────────────────────
434
1210
  server.registerTool('etsquare_get_chunk_context', {
435
1211
  title: 'Get Chunk Context',
436
1212
  description: 'Fetch the highlighted chunk plus surrounding context from the same filing section. ' +
@@ -491,7 +1267,199 @@ server.registerTool('etsquare_get_chunk_context', {
491
1267
  };
492
1268
  }
493
1269
  });
494
- // Start server
1270
+ // ─── Tool 7: Compare Companies ──────────────────────────────────────────────
1271
+ server.registerTool('etsquare_compare', {
1272
+ title: 'Compare Companies in SEC Filings',
1273
+ description: 'Compare 2-5 companies across SEC filings in a single call. ' +
1274
+ 'Returns per-ticker results grouped for easy side-by-side analysis.\n\n' +
1275
+ 'Resolve company names to tickers first with etsquare_lookup_company.\n\n' +
1276
+ 'Use mode_lock=HYBRID to include both narrative text and structured metrics.\n\n' +
1277
+ 'Always returns structured JSON (comparison results are inherently structured).',
1278
+ inputSchema: {
1279
+ tickers: z.array(z.string()).min(2).max(5)
1280
+ .describe('Company tickers to compare (2-5). Resolve names first with etsquare_lookup_company.'),
1281
+ query: z.string().min(3)
1282
+ .describe('Comparison question (e.g., "revenue growth and risk factors", "tariff risk exposure")'),
1283
+ mode_lock: z.enum(['NARRATIVE', 'HYBRID']).default('NARRATIVE')
1284
+ .describe('HYBRID recommended for comparisons (gets both text and metrics)'),
1285
+ top_k_per_ticker: z.number().int().min(1).max(10).default(3)
1286
+ .describe('Narrative results per company'),
1287
+ doc_types: z.array(z.string()).optional()
1288
+ .describe('Limit by form type (e.g., ["10-k", "8-k"])'),
1289
+ },
1290
+ }, async (input) => {
1291
+ if (containsGuardrailBypass(input))
1292
+ return guardrailViolationResponse();
1293
+ try {
1294
+ log('debug', 'Executing comparison', {
1295
+ tickers: input.tickers,
1296
+ query: input.query,
1297
+ mode_lock: input.mode_lock,
1298
+ });
1299
+ const searchResult = await client.search({
1300
+ query: input.query,
1301
+ mode_lock: input.mode_lock,
1302
+ scope_lock: 'COMPANY',
1303
+ tickers: input.tickers,
1304
+ top_k: input.top_k_per_ticker * input.tickers.length,
1305
+ doc_types: input.doc_types,
1306
+ });
1307
+ const allResults = Array.isArray(searchResult.narrative_results)
1308
+ ? searchResult.narrative_results
1309
+ : [];
1310
+ const xbrlMetrics = Array.isArray(searchResult.xbrl_metrics)
1311
+ ? searchResult.xbrl_metrics
1312
+ : [];
1313
+ // Group results by ticker
1314
+ const perTicker = {};
1315
+ for (const ticker of input.tickers) {
1316
+ perTicker[ticker] = allResults
1317
+ .filter((r) => r.ticker === ticker)
1318
+ .slice(0, input.top_k_per_ticker)
1319
+ .map((r, i) => ({
1320
+ index: i + 1,
1321
+ chunk_id: r.chunk_id,
1322
+ company_name: r.company_name,
1323
+ form_type: r.form_type,
1324
+ filing_date: r.filing_date,
1325
+ item_code: r.item_code,
1326
+ section_label: mapItemCodeToLabel(r.item_code),
1327
+ score: r.similarity_score,
1328
+ chunk_text: r.chunk_text,
1329
+ truncated: r.chunk_text_truncated || false,
1330
+ accession_number: r.accession_number,
1331
+ sec_url: r.sec_url,
1332
+ }));
1333
+ }
1334
+ const hasMetrics = xbrlMetrics.length > 0;
1335
+ const metricsColumnMeta = hasMetrics
1336
+ ? buildColumnMeta(searchResult.xbrl_output_schema?.columns || [], xbrlMetrics)
1337
+ : null;
1338
+ const response = {
1339
+ comparison: {
1340
+ tickers: input.tickers,
1341
+ query: input.query,
1342
+ per_ticker: perTicker,
1343
+ metrics: hasMetrics ? {
1344
+ rows: xbrlMetrics,
1345
+ columns: metricsColumnMeta,
1346
+ visualization_hint: deriveMetricsVizHint(xbrlMetrics, { columns: metricsColumnMeta }),
1347
+ } : null,
1348
+ },
1349
+ layout_hint: {
1350
+ suggested: hasMetrics ? 'comparison_dashboard' : 'comparison_grid',
1351
+ reason: 'multi_ticker_compare',
1352
+ panels: hasMetrics
1353
+ ? ['metrics_chart', 'narrative_side_by_side']
1354
+ : ['narrative_side_by_side'],
1355
+ tone: 'comparative',
1356
+ },
1357
+ search_context: buildSearchContext(searchResult, input.query),
1358
+ };
1359
+ const tickerCounts = input.tickers
1360
+ .map(t => `${t}: ${perTicker[t]?.length || 0}`)
1361
+ .join(', ');
1362
+ log('info', `Compare returned results: ${tickerCounts}, ${xbrlMetrics.length} metrics`);
1363
+ return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
1364
+ }
1365
+ catch (error) {
1366
+ log('error', 'Compare failed', { error: error instanceof Error ? error.message : error });
1367
+ return {
1368
+ content: [{ type: 'text', text: `Compare failed: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1369
+ isError: true,
1370
+ };
1371
+ }
1372
+ });
1373
+ // ─── Tool 8: Weekly Brief ───────────────────────────────────────────────────
1374
+ server.registerTool('etsquare_weekly_brief', {
1375
+ title: 'Weekly SEC Filing Intelligence Brief',
1376
+ description: 'Get the latest weekly SEC filing intelligence brief. ' +
1377
+ 'Returns notable 8-K events, edge signals (unusual filings), and sector heatmap. ' +
1378
+ 'Updated weekly. Great for market monitoring and identifying emerging risks.\n\n' +
1379
+ 'Default response_format is "structured" (full JSON). Use "text" for readable summary.',
1380
+ inputSchema: {
1381
+ sections: z.array(z.enum(['notable', 'edge_signals', 'heatmap', 'methodology'])).optional()
1382
+ .describe('Filter to specific sections. Returns all if omitted.'),
1383
+ response_format: z.enum(['text', 'structured']).default('structured')
1384
+ .describe('"structured" returns full JSON (default). "text" returns a readable summary.'),
1385
+ },
1386
+ }, async (input) => {
1387
+ if (containsGuardrailBypass(input))
1388
+ return guardrailViolationResponse();
1389
+ try {
1390
+ log('debug', 'Fetching weekly brief', { sections: input.sections });
1391
+ const brief = await client.weeklyBrief();
1392
+ if (!brief || (!brief.notable && !brief.edge_signals)) {
1393
+ return { content: [{ type: 'text', text: 'No weekly brief available for the current period.' }] };
1394
+ }
1395
+ // Client-side section filtering
1396
+ let output = brief;
1397
+ if (input.sections && input.sections.length > 0) {
1398
+ output = {
1399
+ week_start: brief.week_start,
1400
+ week_end: brief.week_end,
1401
+ generated_at: brief.generated_at,
1402
+ };
1403
+ if (input.sections.includes('notable'))
1404
+ output.notable = brief.notable;
1405
+ if (input.sections.includes('edge_signals'))
1406
+ output.edge_signals = brief.edge_signals;
1407
+ if (input.sections.includes('heatmap'))
1408
+ output.heatmap = brief.heatmap;
1409
+ if (input.sections.includes('methodology'))
1410
+ output.methodology = brief.methodology;
1411
+ }
1412
+ // ── Structured response path ──
1413
+ if (input.response_format === 'structured') {
1414
+ log('info', `Weekly brief returned: ${(brief.notable || []).length} notable, ${(brief.edge_signals || []).length} edge signals`);
1415
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
1416
+ }
1417
+ // ── Text response path ──
1418
+ const lines = [];
1419
+ lines.push(`SEC Weekly Brief: ${brief.week_start || 'N/A'} to ${brief.week_end || 'N/A'}`);
1420
+ lines.push(`Generated: ${brief.generated_at || 'N/A'}`);
1421
+ lines.push('');
1422
+ if (output.notable?.length) {
1423
+ lines.push(`Notable Filings (${output.notable.length}):`);
1424
+ for (const n of output.notable) {
1425
+ lines.push(` ${n.ticker || 'N/A'} — ${n.company_name || 'Unknown'} (${n.form_type || ''} Item ${n.item_code || ''})`);
1426
+ lines.push(` Filed: ${n.filed || 'N/A'} | Score: ${n.score_notable || 'N/A'}`);
1427
+ if (n.summary)
1428
+ lines.push(` ${n.summary}`);
1429
+ if (n.sec_url)
1430
+ lines.push(` ${n.sec_url}`);
1431
+ lines.push('');
1432
+ }
1433
+ }
1434
+ if (output.edge_signals?.length) {
1435
+ lines.push(`Edge Signals (${output.edge_signals.length}):`);
1436
+ for (const e of output.edge_signals) {
1437
+ lines.push(` ${e.ticker || 'N/A'} — ${e.company_name || 'Unknown'}`);
1438
+ if (e.signal_reason)
1439
+ lines.push(` ${e.signal_reason}`);
1440
+ if (e.citation)
1441
+ lines.push(` Verbatim: "${e.citation}"`);
1442
+ lines.push('');
1443
+ }
1444
+ }
1445
+ if (output.heatmap?.length) {
1446
+ lines.push(`Sector Heatmap (${output.heatmap.length} sectors):`);
1447
+ for (const h of output.heatmap) {
1448
+ lines.push(` ${h.sector || h.sic_description || 'N/A'}: ${h.filing_count || h.count || 0} filings`);
1449
+ }
1450
+ }
1451
+ log('info', `Weekly brief returned: ${(brief.notable || []).length} notable, ${(brief.edge_signals || []).length} edge signals`);
1452
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1453
+ }
1454
+ catch (error) {
1455
+ log('error', 'Weekly brief fetch failed', { error: error instanceof Error ? error.message : error });
1456
+ return {
1457
+ content: [{ type: 'text', text: `Weekly brief unavailable: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1458
+ isError: true,
1459
+ };
1460
+ }
1461
+ });
1462
+ // ─── Start Server ───────────────────────────────────────────────────────────
495
1463
  const transport = new StdioServerTransport();
496
1464
  await server.connect(transport);
497
- log('info', 'ETSquare MCP Server ready and listening on stdio');
1465
+ log('info', 'ETSquare MCP Server v0.3.0 ready and listening on stdio (8 tools)');