@etsquare/mcp-server-sec 0.2.1 → 0.5.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,315 @@ 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 metricRows = Array.isArray(apiResponse.xbrl_metrics) ? apiResponse.xbrl_metrics : [];
156
+ const ec = apiResponse.execution_contract || {};
157
+ const narrativeTickers = [...new Set(results.map((r) => r.ticker).filter(Boolean))];
158
+ const metricTickers = [...new Set(metricRows.map((r) => r.ticker).filter(Boolean))];
159
+ const resolvedTickers = [...new Set([...narrativeTickers, ...metricTickers])];
160
+ return {
161
+ query_received: originalQuery,
162
+ query_type: apiResponse.query_type || null,
163
+ scope_confidence: apiResponse.scope_confidence || null,
164
+ tickers_resolved: resolvedTickers,
165
+ tickers_with_narrative: narrativeTickers,
166
+ tickers_with_metrics: metricTickers,
167
+ scope_effective: ec.scope_effective || null,
168
+ mode_effective: ec.mode_effective || null,
169
+ mode_source: ec.mode_source || null,
170
+ scope_source: ec.scope_source || null,
171
+ intent_matched: apiResponse.intent_matched || null,
172
+ results_returned: results.length,
173
+ unique_tickers: resolvedTickers.length,
174
+ unique_forms: [...new Set(results.map((r) => r.form_type).filter(Boolean))],
175
+ template_match: ec.template_match || null,
176
+ relaxations_applied: ec.relaxations_applied || [],
177
+ execution_id: apiResponse.execution_id || null,
178
+ processing_time_ms: apiResponse.processing_time_ms || null,
179
+ };
180
+ }
181
+ function backfillMissingMetricTickerRows(rows, requestedTickers) {
182
+ if (!Array.isArray(rows) || rows.length === 0)
183
+ return rows;
184
+ const requested = requestedTickers.filter(Boolean);
185
+ if (requested.length < 2)
186
+ return rows;
187
+ const covered = new Set(rows
188
+ .map((row) => String(row?.ticker || '').trim().toUpperCase())
189
+ .filter(Boolean));
190
+ const missing = requested.filter((ticker) => !covered.has(String(ticker).trim().toUpperCase()));
191
+ if (missing.length === 0)
192
+ return rows;
193
+ const rowKeys = [];
194
+ const seenKeys = new Set();
195
+ for (const row of rows) {
196
+ if (!row || typeof row !== 'object')
197
+ continue;
198
+ for (const key of Object.keys(row)) {
199
+ if (key === 'ticker' || seenKeys.has(key))
200
+ continue;
201
+ seenKeys.add(key);
202
+ rowKeys.push(key);
203
+ }
204
+ }
205
+ const placeholders = missing.map((ticker) => {
206
+ const placeholder = { ticker };
207
+ for (const key of rowKeys)
208
+ placeholder[key] = null;
209
+ return placeholder;
210
+ });
211
+ return [...rows, ...placeholders];
212
+ }
213
+ function deriveLayoutHint(apiResponse) {
214
+ const results = apiResponse.narrative_results || [];
215
+ const tickers = new Set(results.map((r) => r.ticker));
216
+ const ec = apiResponse.execution_contract || {};
217
+ const scopeEffective = ec.scope_effective || '';
218
+ const hasMetrics = Array.isArray(apiResponse.xbrl_metrics) && apiResponse.xbrl_metrics.length > 0;
219
+ if (tickers.size >= 2 && hasMetrics) {
220
+ return {
221
+ suggested: 'comparison_dashboard',
222
+ reason: 'multi_ticker_with_metrics',
223
+ panels: ['metrics_chart', 'narrative_side_by_side'],
224
+ tone: 'comparative',
225
+ };
226
+ }
227
+ if (tickers.size >= 2) {
228
+ return {
229
+ suggested: 'comparison_grid',
230
+ reason: 'multi_ticker_narrative',
231
+ panels: ['narrative_by_ticker'],
232
+ tone: 'comparative',
233
+ };
234
+ }
235
+ if (tickers.size === 1 && hasMetrics) {
236
+ return {
237
+ suggested: 'company_profile',
238
+ reason: 'single_ticker_hybrid',
239
+ panels: ['metrics_chart', 'evidence_cards'],
240
+ tone: 'analytical',
241
+ };
242
+ }
243
+ if (scopeEffective === 'MACRO') {
244
+ return {
245
+ suggested: 'cross_company_theme',
246
+ reason: 'macro_scope',
247
+ panels: ['theme_summary', 'evidence_by_ticker'],
248
+ tone: 'analytical',
249
+ };
250
+ }
251
+ if (scopeEffective === 'INDUSTRY') {
252
+ return {
253
+ suggested: 'industry_overview',
254
+ reason: 'industry_scope',
255
+ panels: ['sector_summary', 'evidence_by_ticker'],
256
+ tone: 'analytical',
257
+ };
258
+ }
259
+ return {
260
+ suggested: 'evidence_cards',
261
+ reason: 'single_company_narrative',
262
+ alternatives: ['evidence_timeline', 'risk_factor_summary'],
263
+ tone: 'analytical',
264
+ };
265
+ }
266
+ function deriveMetricsVizHint(rows, schema) {
267
+ const columns = schema?.columns || [];
268
+ if (columns.length === 0)
269
+ return { chart_type: 'table' };
270
+ const timeCol = columns.find((c) => c.role === 'time');
271
+ const measureCols = columns.filter((c) => c.role === 'measure');
272
+ const groupCol = columns.find((c) => c.role === 'group');
273
+ if (!timeCol || measureCols.length === 0)
274
+ return { chart_type: 'table' };
275
+ const tickerCol = groupCol?.name || 'ticker';
276
+ const tickers = new Set(rows.map((r) => r[tickerCol]).filter(Boolean));
277
+ const primaryMeasure = measureCols[0];
278
+ const secondaryMeasure = measureCols.find((c) => c.unit === 'pct' && c.name !== primaryMeasure.name);
279
+ return {
280
+ chart_type: tickers.size > 1 ? 'grouped_bar' : 'line',
281
+ x: { field: timeCol.name, order: 'chronological', label: timeCol.label },
282
+ y_primary: {
283
+ field: primaryMeasure.name,
284
+ label: primaryMeasure.label,
285
+ format: primaryMeasure.unit === 'USD_B' ? 'currency_b'
286
+ : primaryMeasure.unit === 'pct' ? 'pct' : 'number',
287
+ },
288
+ y_secondary: secondaryMeasure ? {
289
+ field: secondaryMeasure.name,
290
+ label: secondaryMeasure.label,
291
+ chart_type: 'bar',
292
+ opacity: 0.3,
293
+ format: 'pct',
294
+ } : undefined,
295
+ group_by: tickers.size > 1 ? tickerCol : undefined,
296
+ color_palette: tickers.size > 1 ? 'comparison_diverging' : 'single_accent',
297
+ formats: {
298
+ currency_b: { prefix: '$', suffix: 'B', decimals: 1 },
299
+ pct: { suffix: '%', decimals: 1, sign: true },
300
+ number: { decimals: 0, comma_separated: true },
301
+ },
302
+ };
303
+ }
304
+ function computeSummaryStats(columnNames, rows) {
305
+ if (rows.length === 0)
306
+ return null;
307
+ const numericCols = columnNames.filter(c => typeof rows[0]?.[c] === 'number'
308
+ && !['fiscal_year', 'year'].includes(c));
309
+ if (numericCols.length === 0)
310
+ return null;
311
+ const primary = numericCols[0];
312
+ const values = rows.map(r => r[primary]).filter((v) => v != null);
313
+ if (values.length === 0)
314
+ return null;
315
+ const min = Math.min(...values);
316
+ const max = Math.max(...values);
317
+ return {
318
+ row_count: rows.length,
319
+ primary_measure: primary,
320
+ min,
321
+ max,
322
+ range_pct: min !== 0
323
+ ? Math.round(((max - min) / Math.abs(min)) * 1000) / 10
324
+ : null,
325
+ };
326
+ }
327
+ function buildColumnMeta(apiColumns, rows) {
328
+ const columnNames = apiColumns.length > 0
329
+ ? apiColumns.map((c) => typeof c === 'string' ? c : c.name)
330
+ : (rows.length > 0 ? Object.keys(rows[0]) : []);
331
+ return columnNames.map((col) => ({
332
+ name: col,
333
+ type: inferColumnType(col, rows),
334
+ role: inferColumnRole(col),
335
+ unit: inferColumnUnit(col),
336
+ label: humanizeColumnName(col),
337
+ }));
338
+ }
339
+ // ─── Text Formatting Helpers (existing behavior) ────────────────────────────
340
+ function formatNumberForTable(val) {
341
+ if (val === null || val === undefined)
342
+ return 'N/A';
343
+ if (typeof val === 'number')
344
+ return Number.isInteger(val) ? String(val) : val.toFixed(2);
345
+ return String(val);
346
+ }
347
+ function buildMarkdownTable(colNames, rows, maxRows = 20) {
348
+ const header = colNames.join(' | ');
349
+ const separator = colNames.map(() => '---').join(' | ');
350
+ const displayRows = rows.slice(0, maxRows);
351
+ const tableRows = displayRows.map((row) => colNames.map((col) => formatNumberForTable(row[col])).join(' | '));
352
+ const table = [header, separator, ...tableRows].join('\n');
353
+ const suffix = rows.length > maxRows
354
+ ? `\n(Showing ${maxRows} of ${rows.length} rows)`
355
+ : '';
356
+ return `${table}${suffix}`;
357
+ }
358
+ // ─── MCP Server ─────────────────────────────────────────────────────────────
63
359
  const server = new McpServer({
64
360
  name: 'etsquare-mcp-sec',
65
- version: '0.2.1',
361
+ version: '0.3.0',
66
362
  });
67
- // Tool 1: Company Lookup
363
+ // ─── Tool 1: Company Lookup ─────────────────────────────────────────────────
68
364
  server.registerTool('etsquare_lookup_company', {
69
365
  title: 'Look Up Company Ticker',
70
366
  description: 'Resolve a company name or partial ticker to its official SEC ticker symbol and CIK number. ' +
@@ -107,7 +403,7 @@ server.registerTool('etsquare_lookup_company', {
107
403
  };
108
404
  }
109
405
  });
110
- // Tool 2: SEC Filing Search
406
+ // ─── Tool 2: SEC Filing Search ──────────────────────────────────────────────
111
407
  server.registerTool('etsquare_search', {
112
408
  title: 'Search SEC Filings',
113
409
  description: 'Search 3.4M+ SEC filing sections (10-K, 10-Q, 8-K) with hybrid retrieval. ' +
@@ -115,9 +411,12 @@ server.registerTool('etsquare_search', {
115
411
  'Execution contract (required):\n' +
116
412
  '- mode_lock: NARRATIVE (recommended) | HYBRID\n' +
117
413
  '- 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' +
414
+ 'For canonical financial statements (income statement, balance sheet, cash flow), use etsquare_financial_statements.\n' +
415
+ 'For custom financial queries or peer comparisons, use etsquare_discover_metrics + etsquare_execute_metrics.\n\n' +
416
+ 'For INDUSTRY scope, use sector to target a specific industry (e.g., sector="semiconductors"). ' +
119
417
  '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.',
418
+ 'Results include filing text plus safe citation metadata such as chunk_id, accession number, and SEC URL for follow-up retrieval.\n\n' +
419
+ 'Set response_format="structured" for typed JSON with search_context, layout hints, and chart-ready data.',
121
420
  inputSchema: {
122
421
  query: z.string().min(3).describe('What to search for in SEC filings (e.g., "customer concentration risk", "Show NVDA revenue trend")'),
123
422
  mode_lock: z.enum(['NARRATIVE', 'HYBRID']).describe('NARRATIVE for text search, HYBRID for text + any available metrics'),
@@ -125,7 +424,15 @@ server.registerTool('etsquare_search', {
125
424
  top_k: z.number().min(1).max(50).default(5).describe('Number of results to return (default: 5)'),
126
425
  tickers: z.array(z.string()).optional().describe('Limit to specific companies by ticker (e.g., ["AAPL", "MSFT"])'),
127
426
  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")'),
427
+ sector: z.string().optional().describe('Industry/sector hint for INDUSTRY-scope queries. Resolved internally to SIC codes. ' +
428
+ 'Examples: "software", "fintech", "energy", "semiconductors", "biotech", "REITs", "banks", ' +
429
+ '"defense", "utilities", "oil and gas", "healthcare", "cloud", "cybersecurity". ' +
430
+ 'If sic_codes is also provided, sic_codes takes precedence.'),
431
+ sic_codes: z.array(z.string()).optional().describe('Expert override: limit by SIC industry code (e.g., "7372", "3674"). Takes precedence over sector.'),
432
+ response_format: z.enum(['text', 'structured']).default('text')
433
+ .describe('"text" returns numbered citations (default). "structured" returns JSON with search_context, typed results, and layout hints.'),
434
+ include_context: z.boolean().default(false)
435
+ .describe('Auto-expand top 2 truncated results with surrounding context'),
129
436
  },
130
437
  }, async (input) => {
131
438
  if (containsGuardrailBypass(input))
@@ -136,6 +443,7 @@ server.registerTool('etsquare_search', {
136
443
  mode_lock: input.mode_lock,
137
444
  scope_lock: input.scope_lock,
138
445
  top_k: input.top_k,
446
+ response_format: input.response_format,
139
447
  });
140
448
  const result = await client.search(input);
141
449
  const searchResults = Array.isArray(result.narrative_results)
@@ -158,7 +466,67 @@ server.registerTool('etsquare_search', {
158
466
  text += '\nTry broadening your query or removing filters.';
159
467
  return { content: [{ type: 'text', text }] };
160
468
  }
161
- // Format narrative citations
469
+ // Auto-expand truncated chunks with surrounding context
470
+ if (input.include_context) {
471
+ const truncatedChunks = searchResults
472
+ .filter((r) => r.chunk_text_truncated)
473
+ .slice(0, 2);
474
+ for (const chunk of truncatedChunks) {
475
+ try {
476
+ const ctx = await client.getChunkContext({
477
+ chunk_id: chunk.chunk_id,
478
+ neighbor_span: 1,
479
+ window: 1200,
480
+ });
481
+ const ctxData = ctx.context || {};
482
+ chunk._expanded_context = {
483
+ before: ctxData.before || null,
484
+ highlight: ctxData.highlight || null,
485
+ after: ctxData.after || null,
486
+ };
487
+ }
488
+ catch {
489
+ // Non-fatal — skip context expansion on failure
490
+ }
491
+ }
492
+ }
493
+ // ── Structured response path ──
494
+ if (input.response_format === 'structured') {
495
+ const metricsColumnMeta = xbrlMetrics.length > 0
496
+ ? buildColumnMeta(result.xbrl_output_schema?.columns || [], xbrlMetrics)
497
+ : null;
498
+ const structured = {
499
+ search_context: buildSearchContext(result, input.query),
500
+ results: searchResults
501
+ .slice(0, input.top_k || 5)
502
+ .map((r, i) => ({
503
+ index: i + 1,
504
+ chunk_id: r.chunk_id,
505
+ ticker: r.ticker,
506
+ company_name: r.company_name,
507
+ form_type: r.form_type,
508
+ filing_date: r.filing_date,
509
+ item_code: r.item_code,
510
+ section_label: mapItemCodeToLabel(r.item_code),
511
+ score: r.similarity_score,
512
+ chunk_text: r.chunk_text,
513
+ truncated: r.chunk_text_truncated || false,
514
+ chunk_text_length: r.chunk_text_length || null,
515
+ accession_number: r.accession_number,
516
+ sec_url: r.sec_url,
517
+ expanded_context: r._expanded_context || undefined,
518
+ })),
519
+ metrics: xbrlMetrics.length > 0 ? {
520
+ rows: xbrlMetrics,
521
+ columns: metricsColumnMeta,
522
+ visualization_hint: deriveMetricsVizHint(xbrlMetrics, { columns: metricsColumnMeta }),
523
+ } : null,
524
+ layout_hint: deriveLayoutHint(result),
525
+ };
526
+ log('info', `Search returned ${resultCount} citations, ${xbrlMetrics.length} metrics [structured]`);
527
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
528
+ }
529
+ // ── Text response path (existing behavior) ──
162
530
  const sections = [];
163
531
  if (resultCount > 0) {
164
532
  const formatted = searchResults
@@ -187,6 +555,15 @@ server.registerTool('etsquare_search', {
187
555
  const snippet = r.chunk_text.substring(0, 600);
188
556
  lines.push(` ${snippet}${r.chunk_text.length > 600 ? '...' : ''}`);
189
557
  }
558
+ // Include expanded context if available
559
+ if (r._expanded_context) {
560
+ if (r._expanded_context.before) {
561
+ lines.push(` [Context before]: ${r._expanded_context.before.substring(0, 300)}...`);
562
+ }
563
+ if (r._expanded_context.after) {
564
+ lines.push(` [Context after]: ${r._expanded_context.after.substring(0, 300)}...`);
565
+ }
566
+ }
190
567
  const url = r.sec_url || r.source_url;
191
568
  if (url) {
192
569
  lines.push(` SEC URL: ${url}`);
@@ -202,25 +579,10 @@ server.registerTool('etsquare_search', {
202
579
  const header = headerParts.join('\n');
203
580
  sections.push(`${header}\n\n${formatted}`);
204
581
  }
205
- // Format XBRL metrics if present (handles both wide-format and long-format rows)
206
582
  if (xbrlMetrics.length > 0) {
207
583
  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}`);
584
+ const table = buildMarkdownTable(colNames, xbrlMetrics);
585
+ sections.push(`XBRL Metrics (${xbrlMetrics.length}):\n\n${table}`);
224
586
  }
225
587
  log('info', `Search returned ${resultCount} citations, ${xbrlMetrics.length} metrics`);
226
588
  return {
@@ -238,28 +600,562 @@ server.registerTool('etsquare_search', {
238
600
  };
239
601
  }
240
602
  });
241
- // Tool 3: Execute Metrics (XBRL structured data)
603
+ // ─── Tool 3: Execute Metrics ────────────────────────────────────────────────
604
+ server.registerTool('etsquare_financial_statements', {
605
+ title: 'Get Financial Statements',
606
+ description: 'Get canonical income statement, balance sheet, or cash flow statement for a company. ' +
607
+ 'Assembles structured line items from XBRL facts with concept precedence and provenance.\n\n' +
608
+ 'Each line item includes the selected XBRL concept, priority rank, and unit for auditability.\n\n' +
609
+ 'Coverage: ~5,100 tickers for income statement and cash flow. ' +
610
+ 'Balance sheet coverage is limited for some companies pending XBRL pipeline enhancement.\n\n' +
611
+ 'For custom financial queries or peer comparisons, use etsquare_discover_metrics + etsquare_execute_metrics instead.',
612
+ inputSchema: {
613
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
614
+ statement_type: z.enum(['income_statement', 'balance_sheet', 'cash_flow'])
615
+ .describe('Which financial statement to return'),
616
+ period_mode: z.enum(['latest_annual', 'latest_quarterly', 'last_n_annual', 'last_n_quarterly'])
617
+ .default('last_n_annual')
618
+ .describe('Period selection: latest single period or last N periods'),
619
+ n_periods: z.number().min(1).max(20).default(5)
620
+ .describe('Number of periods for last_n modes (default: 5)'),
621
+ },
622
+ }, async (input) => {
623
+ if (containsGuardrailBypass(input))
624
+ return guardrailViolationResponse();
625
+ try {
626
+ const result = await client.getFinancialStatement(input);
627
+ const periods = Array.isArray(result.periods) ? result.periods : [];
628
+ const lineOrder = Array.isArray(result.line_order) ? result.line_order : [];
629
+ const ticker = result.ticker || input.ticker.toUpperCase();
630
+ if (periods.length === 0) {
631
+ return {
632
+ content: [{
633
+ type: 'text',
634
+ text: `No ${input.statement_type.replace(/_/g, ' ')} data found for ${ticker}. ` +
635
+ `The company may not have XBRL data available for the requested period.`,
636
+ }],
637
+ };
638
+ }
639
+ // Format as markdown table
640
+ const labels = lineOrder.filter(label => {
641
+ return periods.some((p) => p.lines?.[label]?.value != null);
642
+ });
643
+ // Build header row
644
+ const periodHeaders = periods.map((p) => `FY${p.fiscal_year}${p.fiscal_period !== 'FY' ? ' ' + p.fiscal_period : ''}`);
645
+ const formatValue = (val, label) => {
646
+ if (val == null)
647
+ return '—';
648
+ if (label.startsWith('eps_'))
649
+ return val.toFixed(2);
650
+ if (label.startsWith('shares_'))
651
+ return (val / 1e6).toFixed(0) + 'M';
652
+ if (Math.abs(val) >= 1e9)
653
+ return (val / 1e9).toFixed(2) + 'B';
654
+ if (Math.abs(val) >= 1e6)
655
+ return (val / 1e6).toFixed(1) + 'M';
656
+ return val.toLocaleString();
657
+ };
658
+ const labelDisplay = (label) => label.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
659
+ let table = `## ${ticker} — ${input.statement_type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}\n\n`;
660
+ table += `| Line Item | ${periodHeaders.join(' | ')} |\n`;
661
+ table += `|---|${periodHeaders.map(() => '---:').join('|')}|\n`;
662
+ for (const label of labels) {
663
+ const values = periods.map((p) => formatValue(p.lines?.[label]?.value, label));
664
+ table += `| ${labelDisplay(label)} | ${values.join(' | ')} |\n`;
665
+ }
666
+ // Add derived metrics (e.g., FCF)
667
+ const derivedKeys = new Set();
668
+ for (const p of periods) {
669
+ if (p.derived) {
670
+ for (const k of Object.keys(p.derived))
671
+ derivedKeys.add(k);
672
+ }
673
+ }
674
+ if (derivedKeys.size > 0) {
675
+ table += `|---|${periodHeaders.map(() => '---:').join('|')}|\n`;
676
+ for (const dk of derivedKeys) {
677
+ const values = periods.map((p) => formatValue(p.derived?.[dk]?.value, dk));
678
+ table += `| **${labelDisplay(dk)}** | ${values.join(' | ')} |\n`;
679
+ }
680
+ }
681
+ // Provenance note
682
+ const firstPeriod = periods[0];
683
+ const sampleLine = firstPeriod?.lines?.[labels[0]];
684
+ if (sampleLine) {
685
+ table += `\n*Source: SEC XBRL filings. Concept precedence via VT_CF_XBRL_CONCEPT_FAMILIES.*`;
686
+ }
687
+ return {
688
+ content: [{ type: 'text', text: table }],
689
+ };
690
+ }
691
+ catch (error) {
692
+ const message = error instanceof Error ? error.message : 'Unknown error';
693
+ return {
694
+ content: [{ type: 'text', text: `Financial statement request failed: ${message}` }],
695
+ isError: true,
696
+ };
697
+ }
698
+ });
699
+ server.registerTool('etsquare_insider_trades', {
700
+ title: 'Insider Trading Activity',
701
+ description: 'Get insider buying and selling activity for a company from SEC Form 3/4/5 filings. ' +
702
+ 'Shows officer/director stock purchases, sales, option exercises, and tax withholdings.\n\n' +
703
+ 'Data is sourced directly from SEC EDGAR ownership filings, filed within 2 business days of each transaction.\n\n' +
704
+ 'Summary includes open-market buy/sell counts, net shares, and total values. ' +
705
+ 'Derivative transactions (options, RSUs) are separated from open-market trades.',
706
+ inputSchema: {
707
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
708
+ days_back: z.number().min(1).max(730).default(90)
709
+ .describe('Days of history to return (default: 90)'),
710
+ transaction_types: z.array(z.string()).optional()
711
+ .describe('Filter by type: "P" (purchase), "S" (sale), "M" (exercise), "F" (tax withholding), "A" (award)'),
712
+ include_derivatives: z.boolean().default(true)
713
+ .describe('Include derivative transactions like options and RSUs (default: true)'),
714
+ },
715
+ }, async (input) => {
716
+ if (containsGuardrailBypass(input))
717
+ return guardrailViolationResponse();
718
+ try {
719
+ const result = await client.getInsiderTransactions(input);
720
+ const transactions = Array.isArray(result.transactions) ? result.transactions : [];
721
+ const summary = result.summary || {};
722
+ const ticker = result.ticker || input.ticker.toUpperCase();
723
+ if (transactions.length === 0) {
724
+ return {
725
+ content: [{
726
+ type: 'text',
727
+ text: `No insider transactions found for ${ticker} in the last ${input.days_back || 90} days.`,
728
+ }],
729
+ };
730
+ }
731
+ // Format summary
732
+ const fmtVal = (v) => {
733
+ if (v == null)
734
+ return '$0';
735
+ if (Math.abs(v) >= 1e9)
736
+ return `$${(v / 1e9).toFixed(1)}B`;
737
+ if (Math.abs(v) >= 1e6)
738
+ return `$${(v / 1e6).toFixed(1)}M`;
739
+ if (Math.abs(v) >= 1e3)
740
+ return `$${(v / 1e3).toFixed(0)}K`;
741
+ return `$${v.toFixed(0)}`;
742
+ };
743
+ let text = `## ${ticker} — Insider Trading (Last ${input.days_back || 90} Days)\n\n`;
744
+ // Summary line
745
+ const netLabel = (summary.net_shares_open_market ?? 0) >= 0 ? 'net buying' : 'net selling';
746
+ text += `**Open market**: ${summary.open_market_buys || 0} buys (${fmtVal(summary.total_buy_value)}) / `;
747
+ text += `${summary.open_market_sells || 0} sells (${fmtVal(summary.total_sell_value)})`;
748
+ if (summary.exercises)
749
+ text += ` | ${summary.exercises} exercises`;
750
+ if (summary.tax_withholdings)
751
+ text += ` | ${summary.tax_withholdings} tax withholdings`;
752
+ text += `\n\n`;
753
+ // Transaction table — show top 20
754
+ const shown = transactions.slice(0, 20);
755
+ text += `| Date | Insider | Title | Type | Shares | Price | Value |\n`;
756
+ text += `|------|---------|-------|------|-------:|------:|------:|\n`;
757
+ for (const t of shown) {
758
+ const shares = t.shares != null ? t.shares.toLocaleString() : '—';
759
+ const price = t.price_per_share != null ? `$${t.price_per_share.toFixed(2)}` : '—';
760
+ const value = t.total_value != null ? fmtVal(t.total_value) : '—';
761
+ const typeLabel = t.is_derivative ? `${t.transaction_type} *` : t.transaction_type;
762
+ text += `| ${t.transaction_date} | ${t.owner_name} | ${t.officer_title || '—'} | ${typeLabel} | ${shares} | ${price} | ${value} |\n`;
763
+ }
764
+ if (transactions.length > 20) {
765
+ text += `\n*Showing 20 of ${transactions.length} transactions.*\n`;
766
+ }
767
+ text += `\n*\\* = derivative transaction (options/RSUs). Source: SEC Form 4 filings via EDGAR.*`;
768
+ return {
769
+ content: [{ type: 'text', text }],
770
+ };
771
+ }
772
+ catch (error) {
773
+ const message = error instanceof Error ? error.message : 'Unknown error';
774
+ return {
775
+ content: [{ type: 'text', text: `Insider trades request failed: ${message}` }],
776
+ isError: true,
777
+ };
778
+ }
779
+ });
780
+ server.registerTool('etsquare_institutional_holdings', {
781
+ title: 'Institutional Ownership (13F)',
782
+ description: 'Get institutional investor holdings for a company from SEC 13F filings. ' +
783
+ 'Shows tracked managers holding the stock, their position sizes, and portfolio concentration.\n\n' +
784
+ 'Data is from tracked institutional managers (top filers by AUM). ' +
785
+ 'Coverage is partial — not all institutional holders are tracked. ' +
786
+ 'Multiple rows per manager may appear due to sub-manager reporting. ' +
787
+ 'Source: SEC EDGAR 13F-HR filings, filed quarterly.',
788
+ inputSchema: {
789
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "NVDA", "AAPL")'),
790
+ quarters: z.number().min(1).max(8).default(2)
791
+ .describe('Quarters of history (default: 2)'),
792
+ },
793
+ }, async (input) => {
794
+ if (containsGuardrailBypass(input))
795
+ return guardrailViolationResponse();
796
+ try {
797
+ const result = await client.getInstitutionalHoldings(input);
798
+ const summary = result.summary || {};
799
+ const holdersByQuarter = result.holders_by_quarter || {};
800
+ const ticker = result.ticker || input.ticker.toUpperCase();
801
+ const quarters = Object.keys(holdersByQuarter).sort().reverse();
802
+ if (quarters.length === 0) {
803
+ return {
804
+ content: [{
805
+ type: 'text',
806
+ text: `No institutional holdings found for ${ticker} in tracked managers. ` +
807
+ `The stock may not be held by the top institutional filers currently tracked.`,
808
+ }],
809
+ };
810
+ }
811
+ const fmtVal = (v) => {
812
+ if (v == null)
813
+ return '—';
814
+ if (Math.abs(v) >= 1e12)
815
+ return `$${(v / 1e12).toFixed(1)}T`;
816
+ if (Math.abs(v) >= 1e9)
817
+ return `$${(v / 1e9).toFixed(1)}B`;
818
+ if (Math.abs(v) >= 1e6)
819
+ return `$${(v / 1e6).toFixed(1)}M`;
820
+ if (Math.abs(v) >= 1e3)
821
+ return `$${(v / 1e3).toFixed(0)}K`;
822
+ return `$${v.toFixed(0)}`;
823
+ };
824
+ let text = `## ${ticker} — Institutional Holders (13F)\n\n`;
825
+ text += `**Distinct tracked managers**: ${summary.distinct_tracked_managers || 0} | `;
826
+ text += `**Holder rows**: ${summary.holder_rows || 0} | `;
827
+ text += `**Tracked shares**: ${(summary.tracked_shares_held || 0).toLocaleString()} | `;
828
+ text += `**Tracked value**: ${fmtVal(summary.tracked_value_usd)}\n`;
829
+ text += `*${summary.coverage_note || 'Partial coverage — tracked managers only'}*\n\n`;
830
+ for (const q of quarters) {
831
+ const holders = holdersByQuarter[q] || [];
832
+ if (holders.length === 0)
833
+ continue;
834
+ text += `### ${q}\n\n`;
835
+ text += `| Manager | Shares | Value | Portfolio % | Discretion |\n`;
836
+ text += `|---------|-------:|------:|------------:|:-----------|\n`;
837
+ for (const h of holders.slice(0, 20)) {
838
+ const shares = h.shares != null ? h.shares.toLocaleString() : '—';
839
+ const value = fmtVal(h.value_usd);
840
+ const pct = h.portfolio_pct != null ? `${h.portfolio_pct.toFixed(2)}%` : '—';
841
+ const disc = h.investment_discretion || '—';
842
+ const label = h.manager_label || h.manager_name || '—';
843
+ text += `| ${label} | ${shares} | ${value} | ${pct} | ${disc} |\n`;
844
+ }
845
+ if (holders.length > 20) {
846
+ text += `\n*Showing 20 of ${holders.length} tracked holders.*\n`;
847
+ }
848
+ text += '\n';
849
+ }
850
+ text += `*Source: SEC 13F-HR filings via EDGAR. Tracked managers only — not full institutional ownership.*`;
851
+ return {
852
+ content: [{ type: 'text', text }],
853
+ };
854
+ }
855
+ catch (error) {
856
+ const message = error instanceof Error ? error.message : 'Unknown error';
857
+ return {
858
+ content: [{ type: 'text', text: `Institutional holdings request failed: ${message}` }],
859
+ isError: true,
860
+ };
861
+ }
862
+ });
863
+ server.registerTool('etsquare_earnings_actuals', {
864
+ title: 'Earnings Actuals & Guidance',
865
+ description: 'Get issuer-reported earnings actuals and forward guidance for a company from SEC 8-K filings. ' +
866
+ 'Extracts revenue, EPS (basic/diluted), and net income from Item 2.02 press releases.\n\n' +
867
+ 'Values are deterministically extracted from exhibit press releases (EX-99.1) attached to 8-K filings. ' +
868
+ 'Only explicit issuer-reported values are included — no analyst estimates or consensus.\n\n' +
869
+ 'Guidance shows forward-looking ranges (low/high) when the issuer provides them.\n\n' +
870
+ 'Coverage: ~1,900 tickers, most recent 1-2 quarters (Dec 2025 onward). ' +
871
+ 'Historical depth is expanding — some tickers may have fewer quarters than requested.\n\n' +
872
+ 'Source: SEC EDGAR 8-K Item 2.02 filings with attached press releases.',
873
+ inputSchema: {
874
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
875
+ quarters: z.number().min(1).max(20).default(4)
876
+ .describe('Quarters of history (default: 4)'),
877
+ metrics: z.array(z.string()).optional()
878
+ .describe('Filter metrics: "revenue", "diluted_eps", "basic_eps", "net_income"'),
879
+ },
880
+ }, async (input) => {
881
+ if (containsGuardrailBypass(input))
882
+ return guardrailViolationResponse();
883
+ try {
884
+ const result = await client.getEarningsActuals(input);
885
+ const summary = result.summary || {};
886
+ const actuals = result.actuals || [];
887
+ const guidance = result.guidance || [];
888
+ const ticker = result.ticker || input.ticker.toUpperCase();
889
+ if (actuals.length === 0 && guidance.length === 0) {
890
+ return {
891
+ content: [{
892
+ type: 'text',
893
+ text: `No earnings actuals found for ${ticker}. ` +
894
+ `Earnings data is extracted from 8-K Item 2.02 press releases — ` +
895
+ `the company may not have earnings filings in the extracted dataset yet.`,
896
+ }],
897
+ };
898
+ }
899
+ const fmtVal = (v, unit) => {
900
+ if (v == null)
901
+ return '—';
902
+ if (unit === 'USD_PER_SHARE')
903
+ return `$${v.toFixed(2)}`;
904
+ if (Math.abs(v) >= 1e12)
905
+ return `$${(v / 1e12).toFixed(1)}T`;
906
+ if (Math.abs(v) >= 1e9)
907
+ return `$${(v / 1e9).toFixed(2)}B`;
908
+ if (Math.abs(v) >= 1e6)
909
+ return `$${(v / 1e6).toFixed(1)}M`;
910
+ if (Math.abs(v) >= 1e3)
911
+ return `$${(v / 1e3).toFixed(0)}K`;
912
+ return `$${v.toFixed(0)}`;
913
+ };
914
+ let text = `## ${ticker} — Earnings Actuals\n\n`;
915
+ text += `**Metrics found**: ${summary.total_actuals || 0} actuals, ${summary.total_guidance || 0} guidance\n`;
916
+ if (summary.date_range?.earliest && summary.date_range?.latest) {
917
+ text += `**Period**: ${summary.date_range.earliest} to ${summary.date_range.latest}\n`;
918
+ }
919
+ text += '\n';
920
+ if (actuals.length > 0) {
921
+ text += `| Filing Date | Metric | Value | GAAP | FY | FQ | Confidence |\n`;
922
+ text += `|-------------|--------|------:|:----:|---:|---:|-----------:|\n`;
923
+ for (const a of actuals) {
924
+ const val = fmtVal(a.value, a.unit);
925
+ const gaap = a.gaap_flag || '—';
926
+ const fy = a.fiscal_year ?? '—';
927
+ const fq = a.fiscal_quarter ?? '—';
928
+ const conf = a.confidence != null ? `${(a.confidence * 100).toFixed(0)}%` : '—';
929
+ text += `| ${a.filing_date || '—'} | ${a.metric_name} | ${val} | ${gaap} | ${fy} | ${fq} | ${conf} |\n`;
930
+ }
931
+ text += '\n';
932
+ }
933
+ if (guidance.length > 0) {
934
+ text += `### Forward Guidance\n\n`;
935
+ text += `| Filing Date | Metric | Low | High | Midpoint | GAAP | Target FY | Target FQ |\n`;
936
+ text += `|-------------|--------|----:|-----:|---------:|:----:|----------:|----------:|\n`;
937
+ for (const g of guidance) {
938
+ const low = fmtVal(g.low_value, g.unit);
939
+ const high = fmtVal(g.high_value, g.unit);
940
+ const mid = g.midpoint != null ? fmtVal(g.midpoint, g.unit) : '—';
941
+ const gaap = g.gaap_flag || '—';
942
+ const fy = g.fiscal_year ?? '—';
943
+ const fq = g.fiscal_quarter ?? '—';
944
+ text += `| ${g.filing_date || '—'} | ${g.metric_name} | ${low} | ${high} | ${mid} | ${gaap} | ${fy} | ${fq} |\n`;
945
+ }
946
+ text += '\n';
947
+ }
948
+ text += `*Source: SEC 8-K Item 2.02 press releases — deterministic extraction from issuer filings.*`;
949
+ return {
950
+ content: [{ type: 'text', text }],
951
+ };
952
+ }
953
+ catch (error) {
954
+ const message = error instanceof Error ? error.message : 'Unknown error';
955
+ return {
956
+ content: [{ type: 'text', text: `Earnings actuals request failed: ${message}` }],
957
+ isError: true,
958
+ };
959
+ }
960
+ });
961
+ server.registerTool('etsquare_kpi_extractions', {
962
+ title: 'Get Sector KPI Extractions',
963
+ description: 'Query stored KPI extractions from SEC filings for supported sectors such as restaurants, airlines, banks, REITs, pharma, and SaaS.\n\n' +
964
+ 'This returns previously extracted KPI rows with provenance like filing date, fiscal period, and source chunk IDs when available.\n\n' +
965
+ 'Use this for stored non-XBRL operating metrics such as guest traffic, same-store sales, load factor, NIM, deposit trends, or sector-specific KPIs.\n\n' +
966
+ 'Coverage is sector-dependent and may be partial. If coverage is sparse or you need fresh narrative detail, use etsquare_search in NARRATIVE mode.',
967
+ inputSchema: {
968
+ ticker: z.string().min(1).max(10).optional()
969
+ .describe('Single ticker filter (e.g., "TXRH")'),
970
+ sector: z.string().min(2).max(50).optional()
971
+ .describe('Sector key such as "restaurants", "airlines", "banks", "reits", "pharma", or "saas"'),
972
+ sic_code: z.string().length(4).optional()
973
+ .describe('Optional SIC code filter'),
974
+ fiscal_year: z.number().min(1990).max(2100).optional()
975
+ .describe('Optional fiscal year filter'),
976
+ limit: z.number().min(1).max(500).default(50)
977
+ .describe('Maximum rows to return (default: 50)'),
978
+ response_format: z.enum(['text', 'structured']).default('text')
979
+ .describe('"text" returns a readable summary (default). "structured" returns the raw extraction payload as JSON.'),
980
+ },
981
+ }, async (input) => {
982
+ if (containsGuardrailBypass(input))
983
+ return guardrailViolationResponse();
984
+ try {
985
+ const result = await client.getKpiExtractions(input);
986
+ const extractions = Array.isArray(result.extractions)
987
+ ? result.extractions
988
+ : [];
989
+ const filters = result.filters && typeof result.filters === 'object'
990
+ ? result.filters
991
+ : {};
992
+ const count = typeof result.count === 'number'
993
+ ? result.count
994
+ : extractions.length;
995
+ if (input.response_format === 'structured') {
996
+ return {
997
+ content: [{
998
+ type: 'text',
999
+ text: JSON.stringify({
1000
+ count,
1001
+ filters,
1002
+ extractions,
1003
+ }, null, 2),
1004
+ }],
1005
+ };
1006
+ }
1007
+ if (extractions.length === 0) {
1008
+ const filterBits = [
1009
+ input.ticker ? `ticker=${input.ticker.toUpperCase()}` : null,
1010
+ input.sector ? `sector=${input.sector}` : null,
1011
+ input.sic_code ? `sic_code=${input.sic_code}` : null,
1012
+ input.fiscal_year ? `fiscal_year=${input.fiscal_year}` : null,
1013
+ ].filter(Boolean);
1014
+ const suffix = filterBits.length > 0 ? ` for ${filterBits.join(', ')}` : '';
1015
+ return {
1016
+ content: [{
1017
+ type: 'text',
1018
+ text: `No stored KPI extractions found${suffix}. Coverage is sector-dependent. For fresh filing detail, use etsquare_search in NARRATIVE mode.`,
1019
+ }],
1020
+ };
1021
+ }
1022
+ let text = `Found ${count} KPI extraction`;
1023
+ text += count === 1 ? '' : 's';
1024
+ if (Object.keys(filters).length > 0) {
1025
+ const filterText = Object.entries(filters)
1026
+ .map(([key, value]) => `${key}=${value}`)
1027
+ .join(', ');
1028
+ text += ` for ${filterText}`;
1029
+ }
1030
+ text += '.\n\n';
1031
+ for (const extraction of extractions.slice(0, 20)) {
1032
+ const ticker = extraction.ticker || '—';
1033
+ const periodBits = [
1034
+ extraction.form_type || null,
1035
+ extraction.filing_date || null,
1036
+ extraction.fiscal_year != null ? `FY${extraction.fiscal_year}` : null,
1037
+ extraction.fiscal_period || null,
1038
+ ].filter(Boolean);
1039
+ text += `### ${ticker}${extraction.sector ? ` (${extraction.sector})` : ''}\n`;
1040
+ if (periodBits.length > 0) {
1041
+ text += `${periodBits.join(' | ')}\n`;
1042
+ }
1043
+ const kpiJson = extraction.kpi_json && typeof extraction.kpi_json === 'object'
1044
+ ? extraction.kpi_json
1045
+ : null;
1046
+ if (kpiJson) {
1047
+ const entries = Object.entries(kpiJson)
1048
+ .slice(0, 12)
1049
+ .map(([key, value]) => `- ${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`);
1050
+ if (entries.length > 0) {
1051
+ text += `${entries.join('\n')}\n`;
1052
+ }
1053
+ }
1054
+ if (extraction.notes) {
1055
+ text += `Notes: ${extraction.notes}\n`;
1056
+ }
1057
+ text += '\n';
1058
+ }
1059
+ if (extractions.length > 20) {
1060
+ text += `Showing 20 of ${count} stored KPI extractions.\n\n`;
1061
+ }
1062
+ text += 'Coverage is sector-dependent and derived from stored extractions, not live on-demand parsing.';
1063
+ return {
1064
+ content: [{ type: 'text', text }],
1065
+ };
1066
+ }
1067
+ catch (error) {
1068
+ const message = error instanceof Error ? error.message : 'Unknown error';
1069
+ return {
1070
+ content: [{ type: 'text', text: `KPI extraction request failed: ${message}` }],
1071
+ isError: true,
1072
+ };
1073
+ }
1074
+ });
242
1075
  server.registerTool('etsquare_execute_metrics', {
243
1076
  title: 'Execute Financial Metrics Query',
244
1077
  description: 'Execute an XBRL metrics template by template_id to get structured financial data ' +
245
1078
  '(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).',
1079
+ 'Common bind_params: p_tickers (comma-separated tickers), p_ticker (single ticker), p_sic_code (industry), ' +
1080
+ 'p_start_year / p_end_year (time range, e.g., 2023-2025).\n\n' +
1081
+ 'Use the tickers array to pass multiple companies — automatically joins as p_tickers comma-separated string.\n\n' +
1082
+ 'Set response_format="structured" for typed JSON with column metadata, visualization hints, and summary stats.',
248
1083
  inputSchema: {
249
1084
  template_id: z.string().min(10).describe('Template ID for metrics execution'),
250
1085
  bind_params: z.record(z.unknown()).optional().describe('Template parameters as key-value pairs'),
251
1086
  row_limit: z.number().min(1).max(500).optional().describe('Max rows to return (default: 100)'),
1087
+ tickers: z.array(z.string()).max(5).optional()
1088
+ .describe('Execute same template for multiple tickers and merge results. Overrides p_ticker in bind_params.'),
1089
+ response_format: z.enum(['text', 'structured']).default('text')
1090
+ .describe('"text" returns markdown table (default). "structured" returns JSON with column metadata and viz hints.'),
252
1091
  },
253
1092
  }, async (input) => {
254
1093
  if (containsGuardrailBypass(input))
255
1094
  return guardrailViolationResponse();
256
1095
  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 || [];
1096
+ log('debug', 'Executing metrics template', {
1097
+ template_id: input.template_id,
1098
+ tickers: input.tickers,
1099
+ });
1100
+ let rows = [];
1101
+ let columns = [];
1102
+ let rowCount = 0;
1103
+ let truncated = false;
1104
+ let vizHintFromApi = null;
1105
+ // Multi-ticker: join as comma-separated p_tickers (single call)
1106
+ // Templates use p_tickers (plural, comma-separated) for multi-company queries.
1107
+ // Fallback: if p_tickers fails, try N parallel calls with p_ticker (singular).
1108
+ if (input.tickers && input.tickers.length > 0) {
1109
+ const joinedTickers = input.tickers.join(',');
1110
+ const baseParams = { ...(input.bind_params || {}) };
1111
+ // Remove any existing p_ticker — p_tickers takes precedence
1112
+ delete baseParams.p_ticker;
1113
+ try {
1114
+ // Primary: single call with p_tickers (comma-separated)
1115
+ const result = await client.executeMetrics({
1116
+ template_id: input.template_id,
1117
+ bind_params: { ...baseParams, p_tickers: joinedTickers },
1118
+ row_limit: input.row_limit,
1119
+ });
1120
+ rows = result.rows || [];
1121
+ rowCount = result.row_count || rows.length;
1122
+ truncated = result.truncated || false;
1123
+ columns = result.columns || [];
1124
+ vizHintFromApi = result.visualization_hint || null;
1125
+ }
1126
+ catch {
1127
+ // Fallback: parallel calls with p_ticker (singular) per ticker
1128
+ log('debug', 'p_tickers failed, falling back to parallel p_ticker calls');
1129
+ const results = await Promise.allSettled(input.tickers.map(ticker => client.executeMetrics({
1130
+ template_id: input.template_id,
1131
+ bind_params: { ...baseParams, p_ticker: ticker },
1132
+ row_limit: input.row_limit,
1133
+ })));
1134
+ for (const res of results) {
1135
+ if (res.status === 'fulfilled' && res.value) {
1136
+ const val = res.value;
1137
+ if (Array.isArray(val.rows))
1138
+ rows.push(...val.rows);
1139
+ if (!columns.length && Array.isArray(val.columns))
1140
+ columns = val.columns;
1141
+ if (val.truncated)
1142
+ truncated = true;
1143
+ if (!vizHintFromApi)
1144
+ vizHintFromApi = val.visualization_hint || null;
1145
+ }
1146
+ }
1147
+ rowCount = rows.length;
1148
+ }
1149
+ }
1150
+ else {
1151
+ // Single execution
1152
+ const result = await client.executeMetrics(input);
1153
+ rows = result.rows || [];
1154
+ rowCount = result.row_count || rows.length;
1155
+ truncated = result.truncated || false;
1156
+ columns = result.columns || [];
1157
+ vizHintFromApi = result.visualization_hint || null;
1158
+ }
263
1159
  if (rowCount === 0) {
264
1160
  return {
265
1161
  content: [{
@@ -268,21 +1164,31 @@ server.registerTool('etsquare_execute_metrics', {
268
1164
  }],
269
1165
  };
270
1166
  }
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');
1167
+ // ── Structured response path ──
1168
+ if (input.response_format === 'structured') {
1169
+ const columnMeta = buildColumnMeta(columns, rows);
1170
+ // API-returned hint takes precedence over auto-derived
1171
+ const autoHint = deriveMetricsVizHint(rows, { columns: columnMeta });
1172
+ const colNames = columnMeta.map((c) => c.name);
1173
+ const structured = {
1174
+ template_id: input.template_id,
1175
+ columns: columnMeta,
1176
+ rows,
1177
+ row_count: rowCount,
1178
+ truncated,
1179
+ visualization_hint: vizHintFromApi || autoHint,
1180
+ summary_stats: computeSummaryStats(colNames, rows),
1181
+ };
1182
+ log('info', `Metrics query returned ${rowCount} rows [structured]`);
1183
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
1184
+ }
1185
+ // ── Text response path (existing behavior) ──
1186
+ const colNames = columns.length > 0
1187
+ ? columns.map((c) => c.name)
1188
+ : Object.keys(rows[0] || {});
1189
+ const table = buildMarkdownTable(colNames, rows);
284
1190
  const suffix = truncated || rows.length > 20
285
- ? `\n\n(Showing ${displayRows.length} of ${rowCount} rows${truncated ? ', results truncated' : ''})`
1191
+ ? `\n\n(Showing ${Math.min(rows.length, 20)} of ${rowCount} rows${truncated ? ', results truncated' : ''})`
286
1192
  : '';
287
1193
  log('info', `Metrics query returned ${rowCount} rows`);
288
1194
  return {
@@ -300,7 +1206,7 @@ server.registerTool('etsquare_execute_metrics', {
300
1206
  };
301
1207
  }
302
1208
  });
303
- // Tool 4: Discover Metrics Templates
1209
+ // ─── Tool 4: Discover Metrics Templates ─────────────────────────────────────
304
1210
  server.registerTool('etsquare_discover_metrics', {
305
1211
  title: 'Discover Financial Metrics Templates',
306
1212
  description: 'Find available XBRL metrics templates by business question. ' +
@@ -309,12 +1215,15 @@ server.registerTool('etsquare_discover_metrics', {
309
1215
  'Do not use this for narrative KPI questions like same-store sales, comparable sales, traffic, guest count, average check, or AUV. ' +
310
1216
  'For those, use etsquare_search in NARRATIVE mode instead.\n\n' +
311
1217
  '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.',
1218
+ 'Workflow: discover_metrics → pick template_id → execute_metrics with bind_params.\n\n' +
1219
+ 'Set response_format="structured" for JSON with similarity scores and visualization hints.',
313
1220
  inputSchema: {
314
1221
  question: z.string().min(3).describe('Structured metrics question (e.g., "revenue trend by quarter", "gross margin trend", "EPS trend")'),
315
1222
  scenario: z.enum(['snapshot', 'trends', 'peer_benchmark']).optional().describe('Filter by scenario type'),
316
1223
  metric_family: z.string().optional().describe('Filter by metric family: REVENUE, EARNINGS, PROFITABILITY_MARGIN, LEVERAGE_DEBT, FREE_CASH_FLOW, LIQUIDITY'),
317
1224
  max_results: z.number().min(1).max(10).default(5).optional().describe('Max templates to return'),
1225
+ response_format: z.enum(['text', 'structured']).default('text')
1226
+ .describe('"text" returns formatted list (default). "structured" returns JSON with scores and viz hints.'),
318
1227
  },
319
1228
  }, async (input) => {
320
1229
  if (containsGuardrailBypass(input))
@@ -342,13 +1251,32 @@ server.registerTool('etsquare_discover_metrics', {
342
1251
  if (examples.length > 0) {
343
1252
  text += `\nValid metrics examples: ${examples.join('; ')}`;
344
1253
  }
345
- return {
346
- content: [{
347
- type: 'text',
348
- text,
349
- }],
1254
+ return { content: [{ type: 'text', text }] };
1255
+ }
1256
+ // ── Structured response path ──
1257
+ if (input.response_format === 'structured') {
1258
+ const structuredTemplates = templates.map((t, i) => ({
1259
+ template_id: t.template_id,
1260
+ name: t.title,
1261
+ description: t.description,
1262
+ recommended: i === 0,
1263
+ similarity_score: t.similarity_score || null,
1264
+ confidence_score: t.confidence_score || null,
1265
+ scenario: t.scenario,
1266
+ category: t.category,
1267
+ required_params: t.required_params || {},
1268
+ columns: t.columns || [],
1269
+ visualization_hint: t.visualization_hint || null,
1270
+ }));
1271
+ const structured = {
1272
+ question: result.question || input.question,
1273
+ templates: structuredTemplates,
1274
+ guidance: guidance || null,
350
1275
  };
1276
+ log('info', `Discovered ${templates.length} templates [structured]`);
1277
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
351
1278
  }
1279
+ // ── Text response path (existing behavior) ──
352
1280
  const formatted = templates
353
1281
  .map((t, i) => {
354
1282
  const lines = [
@@ -395,7 +1323,7 @@ server.registerTool('etsquare_discover_metrics', {
395
1323
  };
396
1324
  }
397
1325
  });
398
- // Tool 5: Expand a single chunk to full text
1326
+ // ─── Tool 5: Get Full Chunk Text ────────────────────────────────────────────
399
1327
  server.registerTool('etsquare_get_chunk', {
400
1328
  title: 'Get Full Chunk Text',
401
1329
  description: 'Fetch the full text for a specific search result chunk. ' +
@@ -430,7 +1358,7 @@ server.registerTool('etsquare_get_chunk', {
430
1358
  };
431
1359
  }
432
1360
  });
433
- // Tool 6: Expand chunk with surrounding context
1361
+ // ─── Tool 6: Get Chunk Context ──────────────────────────────────────────────
434
1362
  server.registerTool('etsquare_get_chunk_context', {
435
1363
  title: 'Get Chunk Context',
436
1364
  description: 'Fetch the highlighted chunk plus surrounding context from the same filing section. ' +
@@ -491,7 +1419,200 @@ server.registerTool('etsquare_get_chunk_context', {
491
1419
  };
492
1420
  }
493
1421
  });
494
- // Start server
1422
+ // ─── Tool 7: Compare Companies ──────────────────────────────────────────────
1423
+ server.registerTool('etsquare_compare', {
1424
+ title: 'Compare Companies in SEC Filings',
1425
+ description: 'Compare 2-5 companies across SEC filings in a single call. ' +
1426
+ 'Returns per-ticker results grouped for easy side-by-side analysis.\n\n' +
1427
+ 'Resolve company names to tickers first with etsquare_lookup_company.\n\n' +
1428
+ 'Use mode_lock=HYBRID to include both narrative text and structured metrics.\n\n' +
1429
+ 'Always returns structured JSON (comparison results are inherently structured).',
1430
+ inputSchema: {
1431
+ tickers: z.array(z.string()).min(2).max(5)
1432
+ .describe('Company tickers to compare (2-5). Resolve names first with etsquare_lookup_company.'),
1433
+ query: z.string().min(3)
1434
+ .describe('Comparison question (e.g., "revenue growth and risk factors", "tariff risk exposure")'),
1435
+ mode_lock: z.enum(['NARRATIVE', 'HYBRID']).default('NARRATIVE')
1436
+ .describe('HYBRID recommended for comparisons (gets both text and metrics)'),
1437
+ top_k_per_ticker: z.number().int().min(1).max(10).default(3)
1438
+ .describe('Narrative results per company'),
1439
+ doc_types: z.array(z.string()).optional()
1440
+ .describe('Limit by form type (e.g., ["10-k", "8-k"])'),
1441
+ },
1442
+ }, async (input) => {
1443
+ if (containsGuardrailBypass(input))
1444
+ return guardrailViolationResponse();
1445
+ try {
1446
+ log('debug', 'Executing comparison', {
1447
+ tickers: input.tickers,
1448
+ query: input.query,
1449
+ mode_lock: input.mode_lock,
1450
+ });
1451
+ const searchResult = await client.search({
1452
+ query: input.query,
1453
+ mode_lock: input.mode_lock,
1454
+ scope_lock: 'COMPANY',
1455
+ tickers: input.tickers,
1456
+ top_k: input.top_k_per_ticker * input.tickers.length,
1457
+ doc_types: input.doc_types,
1458
+ });
1459
+ const allResults = Array.isArray(searchResult.narrative_results)
1460
+ ? searchResult.narrative_results
1461
+ : [];
1462
+ const rawXbrlMetrics = Array.isArray(searchResult.xbrl_metrics)
1463
+ ? searchResult.xbrl_metrics
1464
+ : [];
1465
+ const xbrlMetrics = backfillMissingMetricTickerRows(rawXbrlMetrics, input.tickers);
1466
+ // Group results by ticker
1467
+ const perTicker = {};
1468
+ for (const ticker of input.tickers) {
1469
+ perTicker[ticker] = allResults
1470
+ .filter((r) => r.ticker === ticker)
1471
+ .slice(0, input.top_k_per_ticker)
1472
+ .map((r, i) => ({
1473
+ index: i + 1,
1474
+ chunk_id: r.chunk_id,
1475
+ company_name: r.company_name,
1476
+ form_type: r.form_type,
1477
+ filing_date: r.filing_date,
1478
+ item_code: r.item_code,
1479
+ section_label: mapItemCodeToLabel(r.item_code),
1480
+ score: r.similarity_score,
1481
+ chunk_text: r.chunk_text,
1482
+ truncated: r.chunk_text_truncated || false,
1483
+ accession_number: r.accession_number,
1484
+ sec_url: r.sec_url,
1485
+ }));
1486
+ }
1487
+ const hasMetrics = xbrlMetrics.length > 0;
1488
+ const metricsColumnMeta = hasMetrics
1489
+ ? buildColumnMeta(searchResult.xbrl_output_schema?.columns || [], xbrlMetrics)
1490
+ : null;
1491
+ const response = {
1492
+ comparison: {
1493
+ tickers: input.tickers,
1494
+ query: input.query,
1495
+ per_ticker: perTicker,
1496
+ metrics: hasMetrics ? {
1497
+ rows: xbrlMetrics,
1498
+ columns: metricsColumnMeta,
1499
+ visualization_hint: deriveMetricsVizHint(xbrlMetrics, { columns: metricsColumnMeta }),
1500
+ } : null,
1501
+ },
1502
+ layout_hint: {
1503
+ suggested: hasMetrics ? 'comparison_dashboard' : 'comparison_grid',
1504
+ reason: 'multi_ticker_compare',
1505
+ panels: hasMetrics
1506
+ ? ['metrics_chart', 'narrative_side_by_side']
1507
+ : ['narrative_side_by_side'],
1508
+ tone: 'comparative',
1509
+ },
1510
+ search_context: buildSearchContext(searchResult, input.query),
1511
+ };
1512
+ const tickerCounts = input.tickers
1513
+ .map(t => `${t}: ${perTicker[t]?.length || 0}`)
1514
+ .join(', ');
1515
+ log('info', `Compare returned results: ${tickerCounts}, ${xbrlMetrics.length} metrics`);
1516
+ return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
1517
+ }
1518
+ catch (error) {
1519
+ log('error', 'Compare failed', { error: error instanceof Error ? error.message : error });
1520
+ return {
1521
+ content: [{ type: 'text', text: `Compare failed: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1522
+ isError: true,
1523
+ };
1524
+ }
1525
+ });
1526
+ // ─── Tool 8: Weekly Brief ───────────────────────────────────────────────────
1527
+ server.registerTool('etsquare_weekly_brief', {
1528
+ title: 'Weekly SEC Filing Intelligence Brief',
1529
+ description: 'Get the latest weekly SEC filing intelligence brief. ' +
1530
+ 'Returns notable 8-K events, edge signals (unusual filings), and sector heatmap. ' +
1531
+ 'Updated weekly. Great for market monitoring and identifying emerging risks.\n\n' +
1532
+ 'Default response_format is "structured" (full JSON). Use "text" for readable summary.',
1533
+ inputSchema: {
1534
+ sections: z.array(z.enum(['notable', 'edge_signals', 'heatmap', 'methodology'])).optional()
1535
+ .describe('Filter to specific sections. Returns all if omitted.'),
1536
+ response_format: z.enum(['text', 'structured']).default('structured')
1537
+ .describe('"structured" returns full JSON (default). "text" returns a readable summary.'),
1538
+ },
1539
+ }, async (input) => {
1540
+ if (containsGuardrailBypass(input))
1541
+ return guardrailViolationResponse();
1542
+ try {
1543
+ log('debug', 'Fetching weekly brief', { sections: input.sections });
1544
+ const brief = await client.weeklyBrief();
1545
+ if (!brief || (!brief.notable && !brief.edge_signals)) {
1546
+ return { content: [{ type: 'text', text: 'No weekly brief available for the current period.' }] };
1547
+ }
1548
+ // Client-side section filtering
1549
+ let output = brief;
1550
+ if (input.sections && input.sections.length > 0) {
1551
+ output = {
1552
+ week_start: brief.week_start,
1553
+ week_end: brief.week_end,
1554
+ generated_at: brief.generated_at,
1555
+ };
1556
+ if (input.sections.includes('notable'))
1557
+ output.notable = brief.notable;
1558
+ if (input.sections.includes('edge_signals'))
1559
+ output.edge_signals = brief.edge_signals;
1560
+ if (input.sections.includes('heatmap'))
1561
+ output.heatmap = brief.heatmap;
1562
+ if (input.sections.includes('methodology'))
1563
+ output.methodology = brief.methodology;
1564
+ }
1565
+ // ── Structured response path ──
1566
+ if (input.response_format === 'structured') {
1567
+ log('info', `Weekly brief returned: ${(brief.notable || []).length} notable, ${(brief.edge_signals || []).length} edge signals`);
1568
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
1569
+ }
1570
+ // ── Text response path ──
1571
+ const lines = [];
1572
+ lines.push(`SEC Weekly Brief: ${brief.week_start || 'N/A'} to ${brief.week_end || 'N/A'}`);
1573
+ lines.push(`Generated: ${brief.generated_at || 'N/A'}`);
1574
+ lines.push('');
1575
+ if (output.notable?.length) {
1576
+ lines.push(`Notable Filings (${output.notable.length}):`);
1577
+ for (const n of output.notable) {
1578
+ lines.push(` ${n.ticker || 'N/A'} — ${n.company_name || 'Unknown'} (${n.form_type || ''} Item ${n.item_code || ''})`);
1579
+ lines.push(` Filed: ${n.filed || 'N/A'} | Score: ${n.score_notable || 'N/A'}`);
1580
+ if (n.summary)
1581
+ lines.push(` ${n.summary}`);
1582
+ if (n.sec_url)
1583
+ lines.push(` ${n.sec_url}`);
1584
+ lines.push('');
1585
+ }
1586
+ }
1587
+ if (output.edge_signals?.length) {
1588
+ lines.push(`Edge Signals (${output.edge_signals.length}):`);
1589
+ for (const e of output.edge_signals) {
1590
+ lines.push(` ${e.ticker || 'N/A'} — ${e.company_name || 'Unknown'}`);
1591
+ if (e.signal_reason)
1592
+ lines.push(` ${e.signal_reason}`);
1593
+ if (e.citation)
1594
+ lines.push(` Verbatim: "${e.citation}"`);
1595
+ lines.push('');
1596
+ }
1597
+ }
1598
+ if (output.heatmap?.length) {
1599
+ lines.push(`Sector Heatmap (${output.heatmap.length} sectors):`);
1600
+ for (const h of output.heatmap) {
1601
+ lines.push(` ${h.sector || h.sic_description || 'N/A'}: ${h.filing_count || h.count || 0} filings`);
1602
+ }
1603
+ }
1604
+ log('info', `Weekly brief returned: ${(brief.notable || []).length} notable, ${(brief.edge_signals || []).length} edge signals`);
1605
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1606
+ }
1607
+ catch (error) {
1608
+ log('error', 'Weekly brief fetch failed', { error: error instanceof Error ? error.message : error });
1609
+ return {
1610
+ content: [{ type: 'text', text: `Weekly brief unavailable: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1611
+ isError: true,
1612
+ };
1613
+ }
1614
+ });
1615
+ // ─── Start Server ───────────────────────────────────────────────────────────
495
1616
  const transport = new StdioServerTransport();
496
1617
  await server.connect(transport);
497
- log('info', 'ETSquare MCP Server ready and listening on stdio');
1618
+ log('info', 'ETSquare MCP Server v0.3.0 ready and listening on stdio (8 tools)');